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 {