Janky animation

This commit is contained in:
Hariharan Mudaliar 2025-11-11 12:36:55 +05:30
commit 29b9f7b460
7 changed files with 311 additions and 9 deletions

View file

@ -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

View file

@ -331,7 +331,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 {
@ -417,6 +423,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)

View file

@ -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" : {

View file

@ -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,243 @@ struct MinimalisticMusicPlayerView: View {
// Compact playback controls // Compact playback controls
playbackControls playbackControls
.padding(.top, 4) .padding(.top, 4)
if shouldShowReminderList {
MinimalisticReminderEventListView(reminders: reminderEntries, currentDate: reminderManager.currentDate)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
} }
.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 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 // MARK: - Visualizer
@Default(.useMusicVisualizer) var useMusicVisualizer @Default(.useMusicVisualizer) var useMusicVisualizer

View file

@ -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")

View file

@ -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 }
} }

View file

@ -135,7 +135,13 @@ 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 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 DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else { guard DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else {