diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 876f07c..4ec415a 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -401,6 +401,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { } .store(in: &cancellables) + TimerManager.shared.$activeSource + .combineLatest(TimerManager.shared.$isTimerActive) + .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/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 0005295..dc3fa1c 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -21,8 +21,11 @@ struct MinimalisticMusicPlayerView: View { @Default(.musicAuxRightControl) private var rightAuxControl @Default(.musicSkipBehavior) private var musicSkipBehavior @ObservedObject private var reminderManager = ReminderLiveActivityManager.shared + @ObservedObject private var timerManager = TimerManager.shared + @ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared @Default(.enableReminderLiveActivity) private var enableReminderLiveActivity @Default(.enableLyrics) private var enableLyrics + @Default(.timerPresets) private var timerPresets var body: some View { VStack(spacing: 0) { @@ -76,6 +79,8 @@ struct MinimalisticMusicPlayerView: View { .padding(.top, 10) } + timerCountdownSection + reminderList } .padding(.horizontal, 12) @@ -83,6 +88,7 @@ struct MinimalisticMusicPlayerView: View { .padding(.bottom, ReminderLiveActivityManager.baselineMinimalisticBottomPadding) .frame(maxWidth: .infinity) .frame(height: calculateDynamicHeight(), alignment: .top) + .animation(.smooth(duration: 0.3), value: dynamicHeightSignature) } // MARK: - TypingLyricView @@ -156,6 +162,37 @@ struct MinimalisticMusicPlayerView: View { ReminderLiveActivityManager.additionalHeight(forRowCount: reminderEntries.count) } + private var shouldShowTimerCountdown: Bool { + coordinator.timerLiveActivityEnabled && timerManager.isExternalTimerActive + } + + private var timerCountdownColor: Color { + let baseColor: Color + if let presetId = timerManager.activePresetId, + let preset = timerPresets.first(where: { $0.id == presetId }) { + baseColor = preset.color + } else { + baseColor = timerManager.timerColor + } + return baseColor.ensureMinimumBrightness(factor: 0.75) + } + + private var timerCountdownText: String { + timerManager.formattedRemainingTime() + } + + private var timerDisplayName: String { + let trimmed = timerManager.timerName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Timer" : trimmed + } + + private var dynamicHeightSignature: Int { + var signature = reminderEntries.count * 10 + if enableLyrics { signature += 1 } + if shouldShowTimerCountdown { signature += 100 } + return signature + } + private func calculateDynamicHeight() -> CGFloat { var height: CGFloat = 50 // Base height for header @@ -172,6 +209,10 @@ struct MinimalisticMusicPlayerView: View { height += lyricsTopPadding + lyricsEstimatedHeight } + if shouldShowTimerCountdown { + height += minimalisticTimerCountdownBlockHeight + } + // Add reminder list height if showing if shouldShowReminderList { height += reminderListHeight @@ -184,6 +225,60 @@ struct MinimalisticMusicPlayerView: View { return height } + private var timerCountdownSection: some View { + VStack(spacing: 0) { + if shouldShowTimerCountdown { + timerCountdownView + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.top, shouldShowTimerCountdown ? minimalisticTimerCountdownTopPadding : 0) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: shouldShowTimerCountdown ? minimalisticTimerCountdownBlockHeight : 0, alignment: .top) + .animation(.smooth(duration: 0.25), value: shouldShowTimerCountdown) + } + + private func displayFont(size: CGFloat) -> Font { + .custom("SF Pro Display", size: size) + } + + private var timerCountdownView: some View { + GeometryReader { geometry in + let availableWidth = geometry.size.width + let preferredCountdownWidth = max(availableWidth * 0.42, 150) + let maxCountdownWidth = max(availableWidth - 60, 0) + let countdownWidth = min(preferredCountdownWidth, maxCountdownWidth) + let marqueeWidth = max(availableWidth - countdownWidth - 12, 0) + + HStack(alignment: .lastTextBaseline, spacing: 12) { + MarqueeText( + .init(get: { timerDisplayName }, set: { _ in }), + font: .system(size: 16, weight: .semibold), + nsFont: .title3, + textColor: timerCountdownColor.opacity(0.85), + frameWidth: max(marqueeWidth, 1) + ) + .alignmentGuide(.lastTextBaseline) { dimensions in + dimensions[VerticalAlignment.bottom] + } + + Spacer(minLength: 8) + + Text(timerCountdownText) + .font(displayFont(size: 56)) + .monospacedDigit() + .foregroundStyle(timerManager.isOvertime ? Color.red : timerCountdownColor) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) + .lineLimit(1) + .frame(width: countdownWidth, alignment: .trailing) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 4) + .frame(height: minimalisticTimerCountdownContentHeight, alignment: .top) + } + private var reminderList: some View { MinimalisticReminderEventListView(reminders: reminderEntries) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index 7d7937c..b52cae1 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -119,6 +119,14 @@ class DynamicIslandViewModel: NSObject, ObservableObject { } .store(in: &cancellables) + TimerManager.shared.$activeSource + .combineLatest(TimerManager.shared.$isTimerActive) + .receive(on: RunLoop.main) + .sink { [weak self] _, _ in + self?.handleMinimalisticTimerHeightChange() + } + .store(in: &cancellables) + coordinator.$statsSecondRowExpansion .removeDuplicates() .receive(on: RunLoop.main) @@ -140,6 +148,23 @@ class DynamicIslandViewModel: NSObject, ObservableObject { } .store(in: &cancellables) } + + private func handleMinimalisticTimerHeightChange() { + guard Defaults[.enableMinimalisticUI] else { return } + guard notchState == .open else { return } + let updatedTarget = calculateDynamicNotchSize() + guard notchSize != updatedTarget else { return } + withAnimation(.smooth) { + notchSize = updatedTarget + } + if let delegate = AppDelegate.shared { + delegate.ensureWindowSize( + addShadowPadding(to: updatedTarget, isMinimalistic: Defaults[.enableMinimalisticUI]), + animated: true, + force: false + ) + } + } private func setupDetectorObserver() { // 1) Publisher for the user’s fullscreen detection setting diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index 9166a96..0e15796 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -17,6 +17,9 @@ let batterySneakSize: CGSize = .init(width: 160, height: 1) let openNotchSize: CGSize = .init(width: 640, height: 190) private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180) private let minimalisticLyricsExtraHeight: CGFloat = 40 +let minimalisticTimerCountdownTopPadding: CGFloat = 12 +let minimalisticTimerCountdownContentHeight: CGFloat = 82 +let minimalisticTimerCountdownBlockHeight: CGFloat = minimalisticTimerCountdownTopPadding + minimalisticTimerCountdownContentHeight let statsSecondRowContentHeight: CGFloat = 120 let statsGridSpacingHeight: CGFloat = 12 let notchShadowPaddingStandard: CGFloat = 18 @@ -36,6 +39,10 @@ var minimalisticOpenNotchSize: CGSize { size.height += reminderHeight } + if DynamicIslandViewCoordinator.shared.timerLiveActivityEnabled && TimerManager.shared.isExternalTimerActive { + size.height += minimalisticTimerCountdownBlockHeight + } + return size } let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14))