From 29b9f7b46016365ebc738bc81a5f63d9e1f6c346 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 11 Nov 2025 12:36:55 +0530 Subject: [PATCH 1/3] Janky animation --- DynamicIsland/ContentView.swift | 8 +- DynamicIsland/DynamicIslandApp.swift | 15 +- DynamicIsland/Localizable.xcstrings | 8 + .../Notch/MinimalisticMusicPlayerView.swift | 238 +++++++++++++++++- .../components/Settings/SettingsView.swift | 6 +- .../ReminderLiveActivityManager.swift | 37 ++- .../models/DynamicIslandViewModel.swift | 8 +- 7 files changed, 311 insertions(+), 9 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index e0a688c..30d33cd 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -39,7 +39,13 @@ struct ContentView: View { // Dynamic sizing based on view type and graph count with smooth transitions 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 { return baseSize diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 653b143..0b1646a 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -331,7 +331,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { } // 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 guard coordinator.currentView == .stats && Defaults[.enableStatsFeature] else { @@ -417,6 +423,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { self?.debouncedUpdateWindowSize() }.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 KeyboardShortcuts.isEnabled = change.newValue }.store(in: &cancellables) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 1418272..1069dba 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -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" : { "localizations" : { "fr" : { @@ -14487,6 +14491,10 @@ }, "Open Google AI Studio" : { + }, + "Open in Calendar" : { + "comment" : "A button label that opens a calendar with the event details.", + "isCommentAutoGenerated" : true }, "Open Model Settings" : { diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index e10e5f9..22eb4a3 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -8,10 +8,16 @@ import SwiftUI import Defaults +#if canImport(AppKit) +import AppKit +#endif + struct MinimalisticMusicPlayerView: View { @EnvironmentObject var vm: DynamicIslandViewModel let albumArtNamespace: Namespace.ID @Default(.showMediaOutputControl) var showMediaOutputControl + @ObservedObject private var reminderManager = ReminderLiveActivityManager.shared + @Default(.enableReminderLiveActivity) private var enableReminderLiveActivity var body: some View { VStack(spacing: 0) { @@ -58,13 +64,243 @@ struct MinimalisticMusicPlayerView: View { // Compact playback controls playbackControls .padding(.top, 4) + + if shouldShowReminderList { + MinimalisticReminderEventListView(reminders: reminderEntries, currentDate: reminderManager.currentDate) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } } .padding(.horizontal, 12) .padding(.top, -15) - .padding(.bottom, 3) + .padding(.bottom, shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding) .frame(maxWidth: .infinity) } + + private var reminderEntries: [ReminderLiveActivityManager.ReminderEntry] { + reminderManager.activeWindowReminders + } + + private var shouldShowReminderList: Bool { + enableReminderLiveActivity && !reminderEntries.isEmpty + } + +private struct MinimalisticReminderEventListView: View { + let reminders: [ReminderLiveActivityManager.ReminderEntry] + let currentDate: Date + + 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, now: currentDate, textFont: textFont, separatorSpacing: separatorSpacing) + } + } + .padding(.top, ReminderLiveActivityManager.listTopPadding) + } +} + +private struct MinimalisticReminderEventRow: View { + let entry: ReminderLiveActivityManager.ReminderEntry + let now: Date + let textFont: Font + let separatorSpacing: CGFloat + + @State private var isDetailsPresented = false + + private let ringDiameter: CGFloat = 20 + private let ringLineWidth: CGFloat = 3 + + var body: some View { + HStack(spacing: separatorSpacing) { + RoundedRectangle(cornerRadius: 3) + .fill(eventColor) + .frame(width: 8, height: ringDiameter) + + Text(entry.event.title) + .font(textFont) + .foregroundStyle(Color.white) + .lineLimit(1) + .layoutPriority(1) + + Spacer(minLength: 12) + + HStack(spacing: separatorSpacing) { + if let url = linkURL { + Button { + copyToClipboard(url: url) + } label: { + Image(systemName: "link") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.85)) + } + .buttonStyle(.plain) + .help("Copy event link") + } + + if hasDetails { + Button { + isDetailsPresented.toggle() + } label: { + Image(systemName: "info.circle") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.85)) + } + .buttonStyle(.plain) + .popover(isPresented: $isDetailsPresented, arrowEdge: .top) { + MinimalisticReminderDetailsView(entry: entry, linkURL: linkURL) + } + } + + ReminderProgressRing(progress: progress, accent: accentColor, diameter: ringDiameter, lineWidth: ringLineWidth) + } + } + .frame(height: ReminderLiveActivityManager.listRowHeight) + } + + private var progress: Double { + guard entry.leadTime > 0 else { return 1 } + let remaining = max(entry.event.start.timeIntervalSince(now), 0) + let elapsed = entry.leadTime - remaining + let ratio = elapsed / entry.leadTime + return min(max(ratio, 0), 1) + } + + private var accentColor: Color { + if isCritical { + return .red + } + return eventColor + } + + private var eventColor: Color { + Color(nsColor: entry.event.calendar.color).ensureMinimumBrightness(factor: 0.7) + } + + private var isCritical: Bool { + let window = TimeInterval(Defaults[.reminderSneakPeekDuration]) + guard window > 0 else { return false } + let remaining = entry.event.start.timeIntervalSince(now) + return remaining > 0 && remaining <= window + } + + 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 func copyToClipboard(url: URL) { +#if canImport(AppKit) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(url.absoluteString, forType: .string) +#endif + } +} + +private struct ReminderProgressRing: View { + let progress: Double + let accent: Color + let diameter: CGFloat + let lineWidth: CGFloat + + var body: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.18), lineWidth: lineWidth) + Circle() + .trim(from: 0, to: progress) + .stroke(accent, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: diameter, height: diameter) + .animation(.smooth(duration: 0.25), value: progress) + } +} + +private struct MinimalisticReminderDetailsView: View { + let entry: ReminderLiveActivityManager.ReminderEntry + let linkURL: URL? + + 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)) + ) + } + + 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 @Default(.useMusicVisualizer) var useMusicVisualizer diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index af8d875..8381fa1 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -38,10 +38,8 @@ struct SettingsView: View { NavigationLink(value: "Media") { Label("Media", systemImage: "play.laptopcomputer") } - if !enableMinimalisticUI { - NavigationLink(value: "Calendar") { - Label("Calendar", systemImage: "calendar") - } + NavigationLink(value: "Calendar") { + Label("Calendar", systemImage: "calendar") } NavigationLink(value: "HUD") { Label("HUDs", systemImage: "dial.medium.fill") diff --git a/DynamicIsland/managers/ReminderLiveActivityManager.swift b/DynamicIsland/managers/ReminderLiveActivityManager.swift index 3d631c0..6fed1da 100644 --- a/DynamicIsland/managers/ReminderLiveActivityManager.swift +++ b/DynamicIsland/managers/ReminderLiveActivityManager.swift @@ -9,6 +9,7 @@ import Combine import Defaults import EventKit import Foundation +import CoreGraphics @MainActor final class ReminderLiveActivityManager: ObservableObject { @@ -21,9 +22,16 @@ final class ReminderLiveActivityManager: ObservableObject { static let shared = ReminderLiveActivityManager() static let standardIconName = "calendar.badge.clock" 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 currentDate: Date = Date() + @Published private(set) var upcomingEntries: [ReminderEntry] = [] + @Published private(set) var activeWindowReminders: [ReminderEntry] = [] private var nextReminder: ReminderEntry? private var cancellables = Set() @@ -113,12 +121,14 @@ final class ReminderLiveActivityManager: ObservableObject { pendingRefreshForce = false refreshTask?.cancel() refreshTask = nil - hasShownCriticalSneakPeek = false + hasShownCriticalSneakPeek = false } private func deactivateReminder() { nextReminder = nil activeReminder = nil + upcomingEntries = [] + activeWindowReminders = [] cancelAllTimers() } @@ -269,6 +279,9 @@ final class ReminderLiveActivityManager: ObservableObject { .compactMap { makeEntry(from: $0, leadMinutes: leadMinutes, referenceDate: referenceDate) } .sorted { $0.triggerDate < $1.triggerDate } + upcomingEntries = upcoming + updateActiveWindowReminders(for: referenceDate) + guard let first = upcoming.first else { deactivateReminder() scheduleFallbackRefresh() @@ -311,6 +324,7 @@ final class ReminderLiveActivityManager: ObservableObject { } currentDate = date + updateActiveWindowReminders(for: date) guard var entry = nextReminder else { activeReminder = nil @@ -385,4 +399,25 @@ final class ReminderLiveActivityManager: ObservableObject { 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 } } diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index c596a55..dcfb9b5 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -135,7 +135,13 @@ class DynamicIslandViewModel: NSObject, ObservableObject { private func calculateDynamicNotchSize() -> CGSize { // Use minimalistic size if minimalistic UI is enabled - let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize + + if Defaults[.enableMinimalisticUI] { + 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 guard DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else { From ba27aa3d0f1d16f1cf0bdf722c1a98678939ec82 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 11 Nov 2025 15:04:59 +0530 Subject: [PATCH 2/3] Things work - but animation is still janky --- DynamicIsland/ContentView.swift | 20 ++- DynamicIsland/DynamicIslandApp.swift | 119 +++++++++--------- .../Notch/MinimalisticMusicPlayerView.swift | 17 ++- .../models/DynamicIslandViewModel.swift | 32 ++++- 4 files changed, 114 insertions(+), 74 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 30d33cd..146cb11 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -87,6 +87,12 @@ struct ContentView: View { @Default(.showNotHumanFace) var showNotHumanFace @Default(.useModernCloseAnimation) var useModernCloseAnimation @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 zeroHeightHoverPadding: CGFloat = 10 @@ -344,8 +350,8 @@ struct ContentView: View { // .keyboardShortcut("E", modifiers: .command) } } - .frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height, alignment: .top) - .animation(.easeInOut(duration: 0.4), value: dynamicNotchSize) + .frame(maxWidth: dynamicNotchSize.width, maxHeight: dynamicNotchSize.height, alignment: .top) + .animation(dynamicNotchResizeAnimation, value: dynamicNotchSize) .animation(.easeInOut(duration: 0.4), value: coordinator.currentView) .environmentObject(privacyManager) .onChange(of: dynamicNotchSize) { oldSize, newSize in @@ -357,9 +363,11 @@ struct ContentView: View { isViewTransitioning = true let workItem = DispatchWorkItem { isViewTransitioning = false + vm.shouldRecheckHover.toggle() } 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) @@ -736,11 +744,15 @@ struct ContentView: View { } } } else { + if vm.notchState == .open && isViewTransitioning { + return + } hoverTask = Task { try? await Task.sleep(for: .milliseconds(100)) guard !Task.isCancelled else { return } await MainActor.run { + guard !self.isViewTransitioning else { return } withAnimation(.bouncy.speed(1.2)) { self.isHovering = false } @@ -800,7 +812,7 @@ struct ContentView: View { let stillNotHovering = !isHovering let stillOpen = vm.notchState == .open let stillNoPopovers = !hasAnyActivePopovers() - let notTransitioning = !isStatsTransitioning && !isSwitchingToStats + let notTransitioning = !isStatsTransitioning && !isSwitchingToStats && !isViewTransitioning if stillNotHovering && stillOpen && stillNoPopovers && notTransitioning { vm.close() diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 0b1646a..a6f2e48 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -79,6 +79,12 @@ struct DynamicNotchApp: App { } } +extension AppDelegate { + static var shared: AppDelegate? { + NSApplication.shared.delegate as? AppDelegate + } +} + class AppDelegate: NSObject, NSApplicationDelegate { var statusItem: NSStatusItem? var windows: [NSScreen: NSWindow] = [:] @@ -249,69 +255,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func updateWindowSizeIfNeeded() { // Calculate required size based on current state let requiredSize = calculateRequiredNotchSize() - - if Defaults[.showOnAllDisplays] { - // 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) - } - } - } + let animateResize = shouldAnimateResize(for: requiredSize) + resizeWindows(to: requiredSize, animated: animateResize, force: false) } private func calculateRequiredNotchSize() -> CGSize { @@ -364,6 +309,54 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Width stays constant - no horizontal expansion 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) { diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 22eb4a3..5f8b8ee 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -65,10 +65,7 @@ struct MinimalisticMusicPlayerView: View { playbackControls .padding(.top, 4) - if shouldShowReminderList { - MinimalisticReminderEventListView(reminders: reminderEntries, currentDate: reminderManager.currentDate) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } + reminderList } .padding(.horizontal, 12) .padding(.top, -15) @@ -83,6 +80,18 @@ struct MinimalisticMusicPlayerView: View { private var shouldShowReminderList: Bool { enableReminderLiveActivity && !reminderEntries.isEmpty } + + private var reminderListHeight: CGFloat { + ReminderLiveActivityManager.additionalHeight(forRowCount: reminderEntries.count) + } + + private var reminderList: some View { + MinimalisticReminderEventListView(reminders: reminderEntries, currentDate: reminderManager.currentDate) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: reminderListHeight, alignment: .top) + .opacity(shouldShowReminderList ? 1 : 0) + .animation(.easeInOut(duration: 0.18), value: shouldShowReminderList) + } private struct MinimalisticReminderEventListView: View { diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index dcfb9b5..d068625 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -44,6 +44,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject { @Published var notchSize: CGSize = getClosedNotchSize() @Published var closedNotchSize: CGSize = getClosedNotchSize() + private var minimalisticReminderRowCount: Int = 0 deinit { destroy() @@ -71,6 +72,19 @@ class DynamicIslandViewModel: NSObject, ObservableObject { .store(in: &cancellables) 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() { @@ -124,8 +138,21 @@ class DynamicIslandViewModel: NSObject, ObservableObject { } 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) { - self.notchSize = calculateDynamicNotchSize() + self.notchSize = targetSize self.notchState = .open } @@ -138,8 +165,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject { var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize if Defaults[.enableMinimalisticUI] { - let reminderCount = ReminderLiveActivityManager.shared.activeWindowReminders.count - let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: reminderCount) + let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: minimalisticReminderRowCount) baseSize.height += extraHeight } From b7cf4dcf0cc975cbb1eba27e94646fba383e4a3c Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 11 Nov 2025 22:23:31 +0530 Subject: [PATCH 3/3] Temporary push: Will fix the animation later --- DynamicIsland/ContentView.swift | 9 +- .../Notch/MinimalisticMusicPlayerView.swift | 164 +++++++++++------- .../models/DynamicIslandViewModel.swift | 1 + 3 files changed, 111 insertions(+), 63 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 146cb11..c0b9a6f 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -215,14 +215,14 @@ struct ContentView: View { } .onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in 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() } } } .onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in 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() } } @@ -230,7 +230,7 @@ struct ContentView: View { .onChange(of: vm.shouldRecheckHover) { _, _ in // Recheck hover state when popovers are closed 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() } } @@ -831,7 +831,8 @@ struct ContentView: View { vm.isColorPickerPopoverActive || vm.isStatsPopoverActive || vm.isTimerPopoverActive || - vm.isMediaOutputPopoverActive + vm.isMediaOutputPopoverActive || + vm.isReminderPopoverActive } // Helper to prevent rapid haptic feedback diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 5f8b8ee..7e00c6d 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -86,17 +86,17 @@ struct MinimalisticMusicPlayerView: View { } private var reminderList: some View { - MinimalisticReminderEventListView(reminders: reminderEntries, currentDate: reminderManager.currentDate) + 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] - let currentDate: Date private let textFont = Font.system(size: 13, weight: .semibold) private let separatorSpacing: CGFloat = 10 @@ -104,7 +104,7 @@ private struct MinimalisticReminderEventListView: View { var body: some View { VStack(alignment: .leading, spacing: ReminderLiveActivityManager.listRowSpacing) { ForEach(reminders) { entry in - MinimalisticReminderEventRow(entry: entry, now: currentDate, textFont: textFont, separatorSpacing: separatorSpacing) + MinimalisticReminderEventRow(entry: entry, textFont: textFont, separatorSpacing: separatorSpacing) } } .padding(.top, ReminderLiveActivityManager.listTopPadding) @@ -113,26 +113,44 @@ private struct MinimalisticReminderEventListView: View { private struct MinimalisticReminderEventRow: View { let entry: ReminderLiveActivityManager.ReminderEntry - let now: Date let textFont: Font let separatorSpacing: CGFloat - @State private var isDetailsPresented = false + @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 ringDiameter: CGFloat = 20 - private let ringLineWidth: CGFloat = 3 + 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: ringDiameter) + .frame(width: 8, height: indicatorHeight) - Text(entry.event.title) - .font(textFont) - .foregroundStyle(Color.white) - .lineLimit(1) - .layoutPriority(1) + 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) @@ -140,10 +158,13 @@ private struct MinimalisticReminderEventRow: View { if let url = linkURL { Button { copyToClipboard(url: url) + triggerCopyFeedback() } label: { - Image(systemName: "link") + Image(systemName: didCopyLink ? "checkmark.circle.fill" : "link") .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Color.white.opacity(0.85)) + .foregroundStyle(didCopyLink ? Color.green : Color.white.opacity(0.85)) + .symbolRenderingMode(.monochrome) + .animation(.easeInOut(duration: 0.2), value: didCopyLink) } .buttonStyle(.plain) .help("Copy event link") @@ -151,50 +172,52 @@ private struct MinimalisticReminderEventRow: View { if hasDetails { Button { - isDetailsPresented.toggle() + isDetailsPopoverPresented.toggle() } label: { Image(systemName: "info.circle") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.white.opacity(0.85)) } .buttonStyle(.plain) - .popover(isPresented: $isDetailsPresented, arrowEdge: .top) { - MinimalisticReminderDetailsView(entry: entry, linkURL: linkURL) + .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() + } } } - - ReminderProgressRing(progress: progress, accent: accentColor, diameter: ringDiameter, lineWidth: ringLineWidth) } } .frame(height: ReminderLiveActivityManager.listRowHeight) - } - - private var progress: Double { - guard entry.leadTime > 0 else { return 1 } - let remaining = max(entry.event.start.timeIntervalSince(now), 0) - let elapsed = entry.leadTime - remaining - let ratio = elapsed / entry.leadTime - return min(max(ratio, 0), 1) - } - - private var accentColor: Color { - if isCritical { - return .red + .contentShape(Rectangle()) + .onTapGesture { + openInCalendar() + } + .onDisappear { + copyResetToken = nil + didCopyLink = false + vm.isReminderPopoverActive = false } - return eventColor } private var eventColor: Color { Color(nsColor: entry.event.calendar.color).ensureMinimumBrightness(factor: 0.7) } - private var isCritical: Bool { - let window = TimeInterval(Defaults[.reminderSneakPeekDuration]) - guard window > 0 else { return false } - let remaining = entry.event.start.timeIntervalSince(now) - return remaining > 0 && remaining <= window - } - private var hasDetails: Bool { let location = entry.event.location?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let notes = entry.event.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -205,6 +228,35 @@ private struct MinimalisticReminderEventRow: View { 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 @@ -212,31 +264,19 @@ private struct MinimalisticReminderEventRow: View { pasteboard.setString(url.absoluteString, forType: .string) #endif } -} -private struct ReminderProgressRing: View { - let progress: Double - let accent: Color - let diameter: CGFloat - let lineWidth: CGFloat - - var body: some View { - ZStack { - Circle() - .stroke(Color.white.opacity(0.18), lineWidth: lineWidth) - Circle() - .trim(from: 0, to: progress) - .stroke(accent, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - .rotationEffect(.degrees(-90)) - } - .frame(width: diameter, height: diameter) - .animation(.smooth(duration: 0.25), value: progress) + 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) @@ -285,6 +325,12 @@ private struct MinimalisticReminderDetailsView: View { 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 { diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index d068625..193b451 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -32,6 +32,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject { @Published var isClipboardPopoverActive: Bool = false @Published var isColorPickerPopoverActive: Bool = false @Published var isStatsPopoverActive: Bool = false + @Published var isReminderPopoverActive: Bool = false @Published var isMediaOutputPopoverActive: Bool = false @Published var isTimerPopoverActive: Bool = false @Published var shouldRecheckHover: Bool = false