diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index cfe724f..0e3b184 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -631,6 +631,7 @@ } }, "%@" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -3314,6 +3315,10 @@ } } }, + "Auto-scroll to next event" : { + "comment" : "A toggle that enables or disables the automatic scrolling to the next event in the calendar view.", + "isCommentAutoGenerated" : true + }, "Automatically check for updates" : { "localizations" : { "ar" : { @@ -8433,6 +8438,10 @@ } } }, + "Event List" : { + "comment" : "A section header in the calendar settings view, describing the list of calendar events.", + "isCommentAutoGenerated" : true + }, "Exclude apps" : { "localizations" : { "en" : { @@ -9548,6 +9557,14 @@ } } }, + "Hide all-day events" : { + "comment" : "A toggle to hide all-day events in the calendar.", + "isCommentAutoGenerated" : true + }, + "Hide completed reminders" : { + "comment" : "A toggle that lets the user hide completed reminders", + "isCommentAutoGenerated" : true + }, "Hide DynamicIsland Options" : { "localizations" : { "fr" : { @@ -11187,6 +11204,14 @@ }, "Manage the Clock timer from the Clock app." : { + }, + "Mark as complete" : { + "comment" : "An accessibility label for a button that marks an item as complete.", + "isCommentAutoGenerated" : true + }, + "Mark as incomplete" : { + "comment" : "An accessibility label for a button that marks an item as incomplete.", + "isCommentAutoGenerated" : true }, "Match menubar height" : { "localizations" : { @@ -13716,6 +13741,10 @@ } } }, + "No events" : { + "comment" : "A description displayed when there are no events in the calendar.", + "isCommentAutoGenerated" : true + }, "No events today" : { "localizations" : { "ar" : { @@ -16992,6 +17021,10 @@ }, "Show floating pause/stop controls" : { + }, + "Show full event titles" : { + "comment" : "A toggle that shows or hides the full event titles in the calendar view", + "isCommentAutoGenerated" : true }, "Show media output control in other layouts" : { "comment" : "A toggle that lets the user enable or disable the media output control in layouts other than the home screen.", diff --git a/DynamicIsland/Providers/CalendarServiceProviding.swift b/DynamicIsland/Providers/CalendarServiceProviding.swift index f7e8a13..963760e 100644 --- a/DynamicIsland/Providers/CalendarServiceProviding.swift +++ b/DynamicIsland/Providers/CalendarServiceProviding.swift @@ -15,6 +15,7 @@ protocol CalendarServiceProviding { func requestAccess(to type: EKEntityType) async -> Bool func calendars() async -> [CalendarModel] func events(from start: Date, to end: Date, calendars: [String]) async -> [EventModel] + func setReminderCompleted(reminderID: String, completed: Bool) async } class CalendarService: CalendarServiceProviding { @@ -122,6 +123,17 @@ class CalendarService: CalendarServiceProviding { } } } + + @MainActor + func setReminderCompleted(reminderID: String, completed: Bool) async { + guard let reminder = store.calendarItem(withIdentifier: reminderID) as? EKReminder else { return } + reminder.isCompleted = completed + do { + try store.save(reminder, commit: true) + } catch { + print("Failed to update reminder completion: \(error)") + } + } } // MARK: - Model Extensions diff --git a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift index fc0b8f4..f5fdd95 100644 --- a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift +++ b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift @@ -10,13 +10,12 @@ import Defaults import EventKit struct Config: Equatable { -// var count: Int = 10 // 3 days past + today + 7 days future - var past: Int = 3 - var future: Int = 7 - var steps: Int = 1 // Each step is one day - var spacing: CGFloat = 1 + var past: Int = 7 + var future: Int = 14 + var steps: Int = 1 + var spacing: CGFloat = 0 var showsText: Bool = true - var offset: Int = 2 // Number of dates to the left of the selected date + var offset: Int = 2 } struct WheelPicker: View { @@ -26,26 +25,28 @@ struct WheelPicker: View { @State private var haptics: Bool = false @State private var byClick: Bool = false let config: Config - + var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: config.spacing) { - let totalSteps = config.steps * (config.past + config.future) let spacerNum = config.offset - ForEach(0.. totalSteps + spacerNum - 1){ - Spacer().frame(width: 24, height: 24).id(index) + let dateCount = totalDateItems() + let totalItems = dateCount + 2 * spacerNum + ForEach(0..= spacerNum + dateCount { + Spacer() + .frame(width: 24, height: 24) + .id(index) } else { - let offset = -config.offset - config.past - let date = dateForIndex(index, offset: offset) - let isSelected = isDateSelected(index, offset: offset) - dateButton(date: date, isSelected: isSelected, offset: offset){ + let date = dateForItemIndex(index: index, spacerNum: spacerNum) + let isSelected = Calendar.current.isDate(date, inSameDayAs: selectedDate) + dateButton(date: date, isSelected: isSelected, id: index) { selectedDate = date byClick = true - withAnimation{ - scrollPosition = indexForDate(date, offset: offset) - config.offset + withAnimation { + scrollPosition = index } - if (Defaults[.enableHaptics]) { + if Defaults[.enableHaptics] { haptics.toggle() } } @@ -56,85 +57,109 @@ struct WheelPicker: View { .scrollTargetLayout() } .scrollIndicators(.never) - .scrollPosition(id: $scrollPosition, anchor: .leading) - .scrollTargetBehavior(.viewAligned) // Ensures scroll view snaps to button center + .scrollPosition(id: $scrollPosition, anchor: .center) + .scrollTargetBehavior(.viewAligned) .safeAreaPadding(.horizontal) .sensoryFeedback(.alignment, trigger: haptics) - .onChange(of: scrollPosition) { oldValue, newValue in - if(!byClick){ + .onChange(of: scrollPosition) { _, newValue in + if !byClick { handleScrollChange(newValue: newValue, config: config) - }else{ + } else { byClick = false } } .onAppear { scrollToToday(config: config) } - } - - private func dateButton(date: Date, isSelected: Bool, offset: Int, onClick:@escaping()->Void) -> some View { - Button(action: onClick) { - VStack(spacing: 2) { - dayText(date: dateToString(for: date), isSelected: isSelected) - dateCircle(date: date, isSelected: isSelected) + .onChange(of: selectedDate) { _, newValue in + let targetIndex = indexForDate(newValue) + if scrollPosition != targetIndex { + byClick = true + withAnimation { + scrollPosition = targetIndex + } } } - .buttonStyle(PlainButtonStyle()) - .id(indexForDate(date, offset: offset)) - } - - private func dayText(date: String, isSelected: Bool) -> some View { - Text(date) - .font(.caption2) - .foregroundStyle(isSelected ? Color.accentColor : .gray) } - private func dateCircle(date: Date, isSelected: Bool) -> some View { + private func dateButton(date: Date, isSelected: Bool, id: Int, onClick: @escaping () -> Void) -> some View { + let isToday = Calendar.current.isDateInToday(date) + return Button(action: onClick) { + VStack(spacing: 8) { + dayText(date: dateToString(for: date), isToday: isToday, isSelected: isSelected) + dateCircle(date: date, isToday: isToday, isSelected: isSelected) + } + .padding(.vertical, 4) + .padding(.horizontal, 4) + .background(isSelected ? Color.effectiveAccentBackground : Color.clear) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + .id(id) + } + + private func dayText(date: String, isToday: Bool, isSelected: Bool) -> some View { + Text(date) + .font(.caption) + .foregroundColor(isSelected ? .white : Color(white: 0.65)) + } + + private func dateCircle(date: Date, isToday: Bool, isSelected: Bool) -> some View { ZStack { Circle() - .fill(isSelected ? Color.accentColor : .clear) - .frame(width: 24, height: 24) - Text("\(date.date)") - .font(.title3) - .foregroundStyle(isSelected ? .white : .gray) + .fill(isToday ? Color.effectiveAccent : .clear) + .frame(width: 20, height: 20) + Text(date.date) + .font(.body) + .fontWeight(.medium) + .foregroundColor(isSelected ? .white : Color(white: isToday ? 0.9 : 0.65)) } } - + func handleScrollChange(newValue: Int?, config: Config) { - let offset = -config.offset - config.past - let todayIndex = indexForDate(Date(), offset: offset) guard let newIndex = newValue else { return } - let targetDateIndex = newIndex + config.offset - switch targetDateIndex { - case todayIndex - config.past ..< todayIndex + config.future: - selectedDate = dateForIndex(targetDateIndex, offset: offset) - if (Defaults[.enableHaptics]) { + let spacerNum = config.offset + let dateCount = totalDateItems() + guard (spacerNum..<(spacerNum + dateCount)).contains(newIndex) else { return } + let date = dateForItemIndex(index: newIndex, spacerNum: spacerNum) + if !Calendar.current.isDate(date, inSameDayAs: selectedDate) { + selectedDate = date + if Defaults[.enableHaptics] { haptics.toggle() } - default: - return } } private func scrollToToday(config: Config) { let today = Date() - let todayIndex = indexForDate(today, offset: -config.offset - config.past) byClick = true - scrollPosition = todayIndex - config.offset + scrollPosition = indexForDate(today) selectedDate = today } - private func indexForDate(_ date: Date, offset: Int) -> Int { - let calendar = Calendar.current - let startDate = calendar.startOfDay(for: calendar.date(byAdding: .day, value: offset, to: Date()) ?? Date()) - let targetDate = calendar.startOfDay(for: date) - let daysDifference = calendar.dateComponents([.day], from: startDate, to: targetDate).day ?? 0 - return daysDifference + private func indexForDate(_ date: Date) -> Int { + let spacerNum = config.offset + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + let startDate = cal.startOfDay(for: cal.date(byAdding: .day, value: -config.past, to: today) ?? today) + let target = cal.startOfDay(for: date) + let days = cal.dateComponents([.day], from: startDate, to: target).day ?? 0 + let stepIndex = max(0, min(days / max(config.steps, 1), totalDateItems() - 1)) + return spacerNum + stepIndex } - private func dateForIndex(_ index: Int, offset: Int) -> Date { - let startDate = Calendar.current.date(byAdding: .day, value: offset, to: Date()) ?? Date() - return Calendar.current.date(byAdding: .day, value: index, to: startDate) ?? Date() + private func dateForItemIndex(index: Int, spacerNum: Int) -> Date { + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + let startDate = cal.date(byAdding: .day, value: -config.past, to: today) ?? today + let stepIndex = index - spacerNum + return cal.date(byAdding: .day, value: stepIndex * max(config.steps, 1), to: startDate) ?? today + } + + private func totalDateItems() -> Int { + let range = config.past + config.future + let step = max(config.steps, 1) + return Int(ceil(Double(range) / Double(step))) + 1 } private func dateToString(for date: Date) -> String { @@ -142,10 +167,6 @@ struct WheelPicker: View { formatter.dateFormat = "E" return formatter.string(from: date) } - - private func isDateSelected(_ index: Int, offset: Int) -> Bool { - Calendar.current.isDate(dateForIndex(index, offset: offset), inSameDayAs: selectedDate) - } } struct CalendarView: View { @@ -187,7 +208,7 @@ struct CalendarView: View { events: calendarManager.events ) if filteredEvents.isEmpty { - EmptyEventsView() + EmptyEventsView(selectedDate: selectedDate) Spacer(minLength: 0) } else { EventListView(events: calendarManager.events) @@ -216,12 +237,14 @@ struct CalendarView: View { } struct EmptyEventsView: View { + let selectedDate: Date + var body: some View { VStack { Image(systemName: "calendar.badge.checkmark") .font(.title) .foregroundColor(Color(white: 0.65)) - Text("No events today") + Text(Calendar.current.isDateInToday(selectedDate) ? "No events today" : "No events") .font(.subheadline) .foregroundColor(.white) Text("Enjoy your free time!") @@ -235,11 +258,16 @@ struct EventListView: View { @Environment(\.openURL) private var openURL @ObservedObject private var calendarManager = CalendarManager.shared let events: [EventModel] - + @Default(.autoScrollToNextEvent) private var autoScrollToNextEvent + @Default(.showFullEventTitles) private var showFullEventTitles static func filteredEvents(events: [EventModel]) -> [EventModel] { events.filter { event in - // Filter out all-day events if setting is enabled + if event.type.isReminder { + if case .reminder(let completed) = event.type { + return !completed || !Defaults[.hideCompletedReminders] + } + } if event.isAllDay && Defaults[.hideAllDayEvents] { return false } @@ -251,42 +279,129 @@ struct EventListView: View { Self.filteredEvents(events: events) } - var body: some View { - List { - ForEach(filteredEvents) { event in - Button(action: { - if let url = event.calendarAppURL() { - openURL(url) - } - }) { - eventRow(event) - } - .padding(.leading, -5) - .buttonStyle(PlainButtonStyle()) - .listRowSeparator(.automatic) - .listRowSeparatorTint(.gray.opacity(0.2)) - .listRowBackground(Color.clear) + private func scrollToRelevantEvent(proxy: ScrollViewProxy) { + guard autoScrollToNextEvent else { return } + let now = Date() + let nonAllDayUpcoming = filteredEvents.first(where: { !$0.isAllDay && $0.end > now }) + let firstAllDay = filteredEvents.first(where: { $0.isAllDay }) + let lastEvent = filteredEvents.last + guard let target = nonAllDayUpcoming ?? firstAllDay ?? lastEvent else { return } + + Task { @MainActor in + withTransaction(Transaction(animation: nil)) { + proxy.scrollTo(target.id, anchor: .top) + } + } + } + + var body: some View { + ScrollViewReader { proxy in + List { + ForEach(filteredEvents) { event in + Button(action: { + if let url = event.calendarAppURL() { + openURL(url) + } + }) { + eventRow(event) + } + .id(event.id) + .padding(.leading, -5) + .buttonStyle(PlainButtonStyle()) + .listRowSeparator(.automatic) + .listRowSeparatorTint(.gray.opacity(0.2)) + .listRowBackground(Color.clear) + } + } + .listStyle(.plain) + .scrollIndicators(.never) + .scrollContentBackground(.hidden) + .background(Color.clear) + .onAppear { + scrollToRelevantEvent(proxy: proxy) + } + .onChange(of: filteredEvents) { _, _ in + scrollToRelevantEvent(proxy: proxy) } } - .listStyle(.plain) - .scrollIndicators(.never) - .scrollContentBackground(.hidden) - .background(Color.clear) Spacer(minLength: 0) } private func eventRow(_ event: EventModel) -> some View { - // Regular event row - return AnyView( - HStack(spacing: 8) { - Circle() - .fill(Color(event.calendar.color)) - .frame(width: 8, height: 8) - HStack { - Text(event.title) - .font(.callout) - .foregroundColor(.white) - .lineLimit(1) + if event.type.isReminder { + let isCompleted: Bool + if case .reminder(let completed) = event.type { + isCompleted = completed + } else { + isCompleted = false + } + return AnyView( + HStack(spacing: 8) { + ReminderToggle( + isOn: Binding( + get: { isCompleted }, + set: { newValue in + Task { + await calendarManager.setReminderCompleted( + reminderID: event.id, completed: newValue + ) + } + } + ), + color: Color(event.calendar.color) + ) + .opacity(1.0) + HStack { + Text(event.title) + .font(.callout) + .foregroundColor(.white) + .lineLimit(showFullEventTitles ? nil : 1) + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 4) { + if event.isAllDay { + Text("All-day") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(1) + } else { + Text(event.start, style: .time) + .foregroundColor(.white) + .font(.caption) + } + } + } + .opacity( + isCompleted + ? 0.4 + : event.start < Date.now && Calendar.current.isDateInToday(event.start) + ? 0.6 : 1.0 + ) + } + .padding(.vertical, 4) + ) + } else { + return AnyView( + HStack(alignment: .top, spacing: 4) { + Rectangle() + .fill(Color(event.calendar.color)) + .frame(width: 3) + .cornerRadius(1.5) + + VStack(alignment: .leading, spacing: 2) { + Text(event.title) + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(showFullEventTitles ? nil : 2) + + if let location = event.location, !location.isEmpty { + Text(location) + .font(.caption) + .foregroundColor(Color(white: 0.65)) + .lineLimit(1) + } + } Spacer(minLength: 0) VStack(alignment: .trailing, spacing: 4) { if event.isAllDay { @@ -296,21 +411,48 @@ struct EventListView: View { .foregroundColor(.white) .lineLimit(1) } else { - Text("\(event.start, style: .time)") - .font(.caption) - .fontWeight(.medium) + Text(event.start, style: .time) .foregroundColor(.white) - .lineLimit(1) - Text("\(event.end, style: .time)") - .font(.caption2) + Text(event.end, style: .time) .foregroundColor(Color(white: 0.65)) - .lineLimit(1) } } + .font(.caption) + .frame(minWidth: 44, alignment: .trailing) } - .contentShape(Rectangle()) + .opacity( + event.eventStatus == .ended && Calendar.current.isDateInToday(event.start) + ? 0.6 : 1.0) + ) + } + } +} + +struct ReminderToggle: View { + @Binding var isOn: Bool + var color: Color + + var body: some View { + Button(action: { + isOn.toggle() + }) { + ZStack { + Circle() + .strokeBorder(color, lineWidth: 2) + .frame(width: 14, height: 14) + if isOn { + Circle() + .fill(color) + .frame(width: 8, height: 8) + } + Circle() + .fill(Color.black.opacity(0.001)) + .frame(width: 14, height: 14) } - ) + } + .buttonStyle(PlainButtonStyle()) + .padding(0) + .accessibilityLabel(isOn ? "Mark as incomplete" : "Mark as complete") } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 69d261e..c1e9691 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -614,6 +614,10 @@ struct SettingsView: View { SettingsSearchEntry(tab: .calendar, title: "Countdown style", keywords: ["reminder countdown"], highlightID: SettingsTab.calendar.highlightID(for: "Countdown style")), SettingsSearchEntry(tab: .calendar, title: "Show lock screen reminder", keywords: ["lock screen", "reminder widget"], highlightID: SettingsTab.calendar.highlightID(for: "Show lock screen reminder")), SettingsSearchEntry(tab: .calendar, title: "Chip color", keywords: ["reminder chip", "color"], highlightID: SettingsTab.calendar.highlightID(for: "Chip color")), + SettingsSearchEntry(tab: .calendar, title: "Hide all-day events", keywords: ["calendar", "all-day"], highlightID: SettingsTab.calendar.highlightID(for: "Hide all-day events")), + SettingsSearchEntry(tab: .calendar, title: "Hide completed reminders", keywords: ["reminder", "completed"], highlightID: SettingsTab.calendar.highlightID(for: "Hide completed reminders")), + SettingsSearchEntry(tab: .calendar, title: "Show full event titles", keywords: ["calendar", "titles"], highlightID: SettingsTab.calendar.highlightID(for: "Show full event titles")), + SettingsSearchEntry(tab: .calendar, title: "Auto-scroll to next event", keywords: ["calendar", "scroll"], highlightID: SettingsTab.calendar.highlightID(for: "Auto-scroll to next event")), // Shelf SettingsSearchEntry(tab: .shelf, title: "Enable shelf", keywords: ["shelf", "dock"], highlightID: SettingsTab.shelf.highlightID(for: "Enable shelf")), @@ -1730,6 +1734,10 @@ struct CalendarSettings: View { @Default(.reminderSneakPeekDuration) var reminderSneakPeekDuration @Default(.enableLockScreenReminderWidget) var enableLockScreenReminderWidget @Default(.lockScreenReminderChipStyle) var lockScreenReminderChipStyle + @Default(.hideAllDayEvents) var hideAllDayEvents + @Default(.hideCompletedReminders) var hideCompletedReminders + @Default(.showFullEventTitles) var showFullEventTitles + @Default(.autoScrollToNextEvent) var autoScrollToNextEvent private func highlightID(_ title: String) -> String { SettingsTab.calendar.highlightID(for: title) @@ -1779,6 +1787,17 @@ struct CalendarSettings: View { Defaults.Toggle("Show calendar", key: .showCalendar) .settingsHighlight(id: highlightID("Show calendar")) + + Section(header: Text("Event List")) { + Toggle("Hide all-day events", isOn: $hideAllDayEvents) + .settingsHighlight(id: highlightID("Hide all-day events")) + Toggle("Hide completed reminders", isOn: $hideCompletedReminders) + .settingsHighlight(id: highlightID("Hide completed reminders")) + Toggle("Show full event titles", isOn: $showFullEventTitles) + .settingsHighlight(id: highlightID("Show full event titles")) + Toggle("Auto-scroll to next event", isOn: $autoScrollToNextEvent) + .settingsHighlight(id: highlightID("Auto-scroll to next event")) + } Section(header: Text("Reminder Live Activity")) { Defaults.Toggle("Enable reminder live activity", key: .enableReminderLiveActivity) diff --git a/DynamicIsland/extensions/Color+AccentColor.swift b/DynamicIsland/extensions/Color+AccentColor.swift new file mode 100644 index 0000000..aba982a --- /dev/null +++ b/DynamicIsland/extensions/Color+AccentColor.swift @@ -0,0 +1,16 @@ +#if os(macOS) +import SwiftUI +import Defaults + +extension Color { + /// Returns the accent color configured in settings, falling back to the system accent color. + static var effectiveAccent: Color { + Defaults[.accentColor] + } + + /// Returns a subtle background variant of the accent color. + static var effectiveAccentBackground: Color { + Defaults[.accentColor].opacity(0.25) + } +} +#endif diff --git a/DynamicIsland/managers/CalendarManager.swift b/DynamicIsland/managers/CalendarManager.swift index a2b5c5a..25440e9 100644 --- a/DynamicIsland/managers/CalendarManager.swift +++ b/DynamicIsland/managers/CalendarManager.swift @@ -203,4 +203,9 @@ class CalendarManager: ObservableObject { ) self.events = eventsResult } + + func setReminderCompleted(reminderID: String, completed: Bool) async { + await calendarService.setReminderCompleted(reminderID: reminderID, completed: completed) + await updateEvents() + } } diff --git a/DynamicIsland/managers/ReminderLiveActivityManager.swift b/DynamicIsland/managers/ReminderLiveActivityManager.swift index ad03ca9..90b7240 100644 --- a/DynamicIsland/managers/ReminderLiveActivityManager.swift +++ b/DynamicIsland/managers/ReminderLiveActivityManager.swift @@ -7,7 +7,6 @@ import Combine import Defaults -import EventKit import Foundation import CoreGraphics import os @@ -36,59 +35,23 @@ final class ReminderLiveActivityManager: ObservableObject { private let logger: os.Logger = os.Logger(subsystem: "com.ebullioscopic.Atoll", category: "ReminderLiveActivity") - private enum RefreshReason: String { - case initialization - case defaults - case manual - case eventStoreChange - case calendarListChange - case calendarEventsUpdate - case evaluation - case hover - case ticker - case other - } - private var nextReminder: ReminderEntry? private var cancellables = Set() private var tickerTask: Task? { didSet { oldValue?.cancel() } } private var evaluationTask: Task? - private var refreshTask: Task? - private var pendingRefreshTask: Task? - private var pendingRefreshForce = false - private var pendingRefreshToken = UUID() - private var pendingRefreshReason: RefreshReason = .other - private var lastKnownCalendarIDs = Set() - private var lastRefreshDate: Date? - private var lastRefreshCompletionDate: Date? - private let minimumRefreshInterval: TimeInterval = 60 - private let refreshDebounceInterval: TimeInterval = 0.3 - private var refreshTaskToken = UUID() private var hasShownCriticalSneakPeek = false - private let eventStoreChangeNoiseInterval: TimeInterval = 4 - private let eventStoreForceThrottleInterval: TimeInterval = 20 - private let manualRefreshSuppressionInterval: TimeInterval = 20 - private var lastEventStoreForceRefreshDate: Date? - private var lastManualRefreshDate: Date? - private var pendingEventStoreChange: (date: Date, reason: RefreshReason)? - private var reminderCompletionSuppressionDeadline: Date? - private let reminderCompletionSuppressionInterval: TimeInterval = 15 - private var lastUpcomingSignature: [String] = [] - private var lastNoopEventStoreRefreshDate: Date? - private let noopEventStoreSuppressionInterval: TimeInterval = 30 - private var lastCalendarEventSnapshot: [EventModel] = [] - private var lastCalendarSnapshotDate: Date? - private var lastCalendarEventSnapshotSignature: [String] = [] + private var latestEvents: [EventModel] = [] - private let calendarService: CalendarServiceProviding private let calendarManager = CalendarManager.shared var isActive: Bool { activeReminder != nil } - private init(calendarService: CalendarServiceProviding = CalendarService()) { - self.calendarService = calendarService + private init() { + latestEvents = calendarManager.events setupObservers() - scheduleRefresh(force: true, reason: .initialization) + if !latestEvents.isEmpty { + recalculateUpcomingEntries(reason: "initialization") + } } private func setupObservers() { @@ -96,7 +59,7 @@ final class ReminderLiveActivityManager: ObservableObject { .sink { [weak self] change in guard let self else { return } if change.newValue { - self.scheduleRefresh(force: true, reason: .defaults) + self.recalculateUpcomingEntries(reason: "defaults-toggle") } else { self.deactivateReminder() } @@ -106,7 +69,19 @@ final class ReminderLiveActivityManager: ObservableObject { Defaults.publisher(.reminderLeadTime, options: []) .sink { [weak self] _ in guard let self else { return } - self.scheduleRefresh(force: true, reason: .defaults) + self.recalculateUpcomingEntries(reason: "lead-time") + } + .store(in: &cancellables) + + Defaults.publisher(.hideAllDayEvents, options: []) + .sink { [weak self] _ in + self?.recalculateUpcomingEntries(reason: "hide-all-day") + } + .store(in: &cancellables) + + Defaults.publisher(.hideCompletedReminders, options: []) + .sink { [weak self] _ in + self?.recalculateUpcomingEntries(reason: "hide-completed") } .store(in: &cancellables) @@ -120,16 +95,6 @@ final class ReminderLiveActivityManager: ObservableObject { } .store(in: &cancellables) - calendarManager.$allCalendars - .sink { [weak self] calendars in - guard let self else { return } - let identifiers = Set(calendars.map { $0.id }) - guard identifiers != self.lastKnownCalendarIDs else { return } - self.lastKnownCalendarIDs = identifiers - self.handleEventStoreDrivenChange(reason: .calendarListChange) - } - .store(in: &cancellables) - calendarManager.$events .receive(on: RunLoop.main) .sink { [weak self] events in @@ -137,26 +102,13 @@ final class ReminderLiveActivityManager: ObservableObject { self.handleCalendarEventsUpdate(events) } .store(in: &cancellables) - - NotificationCenter.default.publisher(for: .EKEventStoreChanged) - .sink { [weak self] _ in - guard let self else { return } - self.handleEventStoreDrivenChange(reason: .eventStoreChange) - } - .store(in: &cancellables) } private func cancelAllTimers() { tickerTask = nil evaluationTask?.cancel() evaluationTask = nil - pendingRefreshTask?.cancel() - pendingRefreshTask = nil - pendingRefreshForce = false - refreshTask?.cancel() - refreshTask = nil hasShownCriticalSneakPeek = false - pendingEventStoreChange = nil } private func deactivateReminder() { @@ -165,32 +117,13 @@ final class ReminderLiveActivityManager: ObservableObject { upcomingEntries = [] activeWindowReminders = [] cancelAllTimers() - lastUpcomingSignature = [] - lastNoopEventStoreRefreshDate = nil - } - - private func selectedCalendarIDs() -> [String] { - calendarManager.allCalendars - .filter { calendarManager.getCalendarSelected($0) } - .map { $0.id } } private func handleCalendarEventsUpdate(_ events: [EventModel]) { - let now = Date() - let signature = events.map { $0.id } - if signature == lastCalendarEventSnapshotSignature, - let snapshotDate = lastCalendarSnapshotDate, - now.timeIntervalSince(snapshotDate) < 5 { - return - } - - lastCalendarEventSnapshot = events - lastCalendarSnapshotDate = now - lastCalendarEventSnapshotSignature = signature - + latestEvents = events guard Defaults[.enableReminderLiveActivity] else { return } logger.debug("[Reminder] Applying calendar snapshot update (events=\(events.count, privacy: .public))") - refreshFromEvents(events, referenceDate: now, reason: .calendarEventsUpdate) + recalculateUpcomingEntries(reason: "calendar-events") } private func shouldHide(_ event: EventModel) -> Bool { @@ -211,6 +144,45 @@ final class ReminderLiveActivityManager: ObservableObject { return ReminderEntry(event: event, triggerDate: trigger, leadTime: TimeInterval(leadSeconds)) } + private func recalculateUpcomingEntries(referenceDate: Date = Date(), reason: String) { + guard Defaults[.enableReminderLiveActivity] else { + deactivateReminder() + return + } + + guard calendarManager.hasReminderAccess else { + deactivateReminder() + return + } + + let leadMinutes = Defaults[.reminderLeadTime] + let upcoming = latestEvents + .filter { !shouldHide($0) } + .compactMap { makeEntry(from: $0, leadMinutes: leadMinutes, referenceDate: referenceDate) } + .sorted { $0.triggerDate < $1.triggerDate } + + upcomingEntries = upcoming + updateActiveWindowReminders(for: referenceDate) + + guard let first = upcoming.first else { + clearActiveReminderState() + logger.debug("[Reminder] No upcoming reminders found (reason=\(reason, privacy: .public))") + return + } + + logger.debug("[Reminder] Next reminder ‘\(first.event.title, privacy: .public)’ (reason=\(reason, privacy: .public))") + handleEntrySelection(first, referenceDate: referenceDate) + } + + private func clearActiveReminderState() { + nextReminder = nil + if activeReminder != nil { + activeReminder = nil + } + activeWindowReminders = [] + cancelAllTimers() + } + private func scheduleEvaluation(at date: Date) { evaluationTask?.cancel() let delay = date.timeIntervalSinceNow @@ -226,88 +198,6 @@ final class ReminderLiveActivityManager: ObservableObject { } } - private func scheduleRefresh(force: Bool, reason: RefreshReason = .other) { - let wasPending = pendingRefreshTask != nil - pendingRefreshForce = pendingRefreshForce || force - pendingRefreshToken = UUID() - pendingRefreshReason = reason - let token = pendingRefreshToken - - logger.debug("[Reminder] scheduleRefresh reason=\(reason.rawValue, privacy: .public) force=\(force, privacy: .public) pending=\(wasPending, privacy: .public)") - - refreshTask?.cancel() - pendingRefreshTask?.cancel() - pendingRefreshTask = Task { [weak self] in - guard let self else { return } - do { - try await Task.sleep(nanoseconds: UInt64(self.refreshDebounceInterval * 1_000_000_000)) - } catch { - self.logger.debug("[Reminder] Debounced refresh for reason=\(reason.rawValue, privacy: .public) was cancelled") - return - } - await self.executeScheduledRefresh(token: token) - } - } - - private func executeScheduledRefresh(token: UUID) async { - guard pendingRefreshToken == token else { return } - pendingRefreshTask = nil - - if Task.isCancelled { - logger.debug("[Reminder] Cancelled refresh token=\(token.uuidString, privacy: .public)") - return - } - - let reason = pendingRefreshReason - let now = Date() - let force = pendingRefreshForce - if !force, let last = lastRefreshDate { - let elapsed = now.timeIntervalSince(last) - if elapsed < minimumRefreshInterval { - let remaining = max(minimumRefreshInterval - elapsed, refreshDebounceInterval) - logger.debug("[Reminder] Deferring refresh reason=\(reason.rawValue, privacy: .public); next attempt in \(remaining, format: .fixed(precision: 2))s") - pendingRefreshToken = UUID() - let nextToken = pendingRefreshToken - pendingRefreshTask = Task { [weak self] in - guard let self else { return } - do { - try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) - } catch { - self.logger.debug("[Reminder] Deferred refresh for reason=\(reason.rawValue, privacy: .public) was cancelled") - return - } - await self.executeScheduledRefresh(token: nextToken) - } - return - } - } - - pendingRefreshForce = false - lastRefreshDate = now - - let startTime = Date() - logger.debug("[Reminder] Starting refresh reason=\(reason.rawValue, privacy: .public) force=\(force, privacy: .public)") - - refreshTask?.cancel() - let taskToken = UUID() - refreshTaskToken = taskToken - let task = Task { [weak self] in - guard let self else { return } - await self.refreshUpcomingReminder(force: force, reason: reason) - } - refreshTask = task - defer { - if refreshTaskToken == taskToken { - refreshTask = nil - lastRefreshCompletionDate = Date() - let duration = Date().timeIntervalSince(startTime) - logger.debug("[Reminder] Finished refresh reason=\(reason.rawValue, privacy: .public) duration=\(duration, format: .fixed(precision: 2))s upcoming=\(self.upcomingEntries.count, privacy: .public)") - processPendingEventStoreChangeIfNeeded() - } - } - _ = try? await task.value - } - private func startTickerIfNeeded() { guard tickerTask == nil else { return } tickerTask = Task { [weak self] in @@ -328,109 +218,16 @@ final class ReminderLiveActivityManager: ObservableObject { } private func handleEntrySelection(_ entry: ReminderEntry?, referenceDate: Date) { + guard nextReminder != entry else { + Task { await self.evaluateCurrentState(at: referenceDate) } + return + } + nextReminder = entry hasShownCriticalSneakPeek = false Task { await self.evaluateCurrentState(at: referenceDate) } } - private func refreshFromEvents(_ events: [EventModel], referenceDate: Date, reason: RefreshReason) { - let leadMinutes = Defaults[.reminderLeadTime] - let upcoming = events - .filter { !shouldHide($0) } - .compactMap { makeEntry(from: $0, leadMinutes: leadMinutes, referenceDate: referenceDate) } - .sorted { $0.triggerDate < $1.triggerDate } - - upcomingEntries = upcoming - updateActiveWindowReminders(for: referenceDate) - - logger.debug("[Reminder] Reduced \(events.count, privacy: .public) events to \(upcoming.count, privacy: .public) upcoming reminders") - - guard let first = upcoming.first else { - deactivateReminder() - logger.debug("[Reminder] No upcoming reminders found; awaiting next calendar update") - return - } - - logger.debug("[Reminder] Next reminder ‘\(first.event.title, privacy: .public)’ at \(first.triggerDate.timeIntervalSinceReferenceDate, privacy: .public)") - - handleEntrySelection(first, referenceDate: referenceDate) - - let signature = upcoming.map { $0.event.id } - if lastUpcomingSignature == signature { - if reason == .eventStoreChange { - lastNoopEventStoreRefreshDate = Date() - } - } else { - lastNoopEventStoreRefreshDate = nil - } - lastUpcomingSignature = signature - } - - func requestRefresh(force: Bool = false) { - let reason: RefreshReason = force ? .manual : .other - if force { - let now = Date() - if let lastManualRefreshDate, - now.timeIntervalSince(lastManualRefreshDate) < manualRefreshSuppressionInterval { - let delta = now.timeIntervalSince(lastManualRefreshDate) - logger.debug("[Reminder] Skipping manual refresh; last manual was \(delta, format: .fixed(precision: 2))s ago") - return - } - lastManualRefreshDate = now - } - scheduleRefresh(force: force, reason: reason) - } - - private func refreshUpcomingReminder(force: Bool = false, reason: RefreshReason = .other) async { - guard Defaults[.enableReminderLiveActivity] else { - deactivateReminder() - logger.debug("[Reminder] Refresh aborted (feature disabled)") - return - } - - guard calendarManager.hasReminderAccess else { - deactivateReminder() - logger.debug("[Reminder] Refresh aborted; reminder permissions missing") - return - } - - let now = Date() - - if !force, let entry = nextReminder, entry.event.start > now { - logger.debug("[Reminder] Skipping fetch; cached entry \(entry.event.title, privacy: .public) still valid for reason=\(reason.rawValue, privacy: .public)") - await evaluateCurrentState(at: now) - return - } - - if let snapshotDate = lastCalendarSnapshotDate, - now.timeIntervalSince(snapshotDate) < 5, - !lastCalendarEventSnapshot.isEmpty { - logger.debug("[Reminder] Using calendar snapshot (age=\(now.timeIntervalSince(snapshotDate), format: .fixed(precision: 2))s) for reason=\(reason.rawValue, privacy: .public)") - refreshFromEvents(lastCalendarEventSnapshot, referenceDate: now, reason: reason) - return - } - - let calendars = selectedCalendarIDs() - guard !calendars.isEmpty else { - deactivateReminder() - logger.debug("[Reminder] Refresh aborted; no calendars selected") - return - } - - let windowEnd = Calendar.current.date(byAdding: .hour, value: 24, to: now) ?? now.addingTimeInterval(24 * 60 * 60) - logger.debug("[Reminder] Fetching reminders (reason=\(reason.rawValue, privacy: .public), force=\(force, privacy: .public), calendars=\(calendars.count, privacy: .public))") - let fetchStart = Date() - let events = await calendarService.events(from: now, to: windowEnd, calendars: calendars) - let fetchDuration = Date().timeIntervalSince(fetchStart) - logger.debug("[Reminder] EventKit fetch completed (events=\(events.count, privacy: .public)) in \(fetchDuration, format: .fixed(precision: 2))s") - lastCalendarEventSnapshot = events - lastCalendarSnapshotDate = now - lastCalendarEventSnapshotSignature = events.map { $0.id } - await MainActor.run { - self.refreshFromEvents(events, referenceDate: now, reason: reason) - } - } - func evaluateCurrentState(at date: Date) async { guard Defaults[.enableReminderLiveActivity] else { deactivateReminder() @@ -450,13 +247,9 @@ final class ReminderLiveActivityManager: ObservableObject { } if entry.event.start <= date { - activeReminder = nil - nextReminder = nil - stopTicker() - hasShownCriticalSneakPeek = false - logger.debug("[Reminder] Reminder reached start time; requesting evaluation refresh") - reminderCompletionSuppressionDeadline = Date().addingTimeInterval(reminderCompletionSuppressionInterval) - scheduleRefresh(force: true, reason: .evaluation) + clearActiveReminderState() + logger.debug("[Reminder] Reminder reached start time; reevaluating reminders from cache") + recalculateUpcomingEntries(referenceDate: date, reason: "evaluation-complete") return } @@ -528,83 +321,6 @@ final class ReminderLiveActivityManager: ObservableObject { } } - private func handleEventStoreDrivenChange(reason: RefreshReason) { - processEventStoreChange(at: Date(), reason: reason) - } - - private func processEventStoreChange(at date: Date, reason: RefreshReason) { - if let manual = lastManualRefreshDate { - let delta = date.timeIntervalSince(manual) - if delta < manualRefreshSuppressionInterval { - logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public) change; manual refresh cooldown \(delta, format: .fixed(precision: 2))s") - return - } - } - - if let lastRefreshStart = lastRefreshDate { - let sinceStart = date.timeIntervalSince(lastRefreshStart) - if sinceStart >= 0 && sinceStart < eventStoreChangeNoiseInterval { - logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); \(sinceStart, format: .fixed(precision: 2))s since start") - return - } - } - - if let lastCompletion = lastRefreshCompletionDate { - let sinceCompletion = abs(date.timeIntervalSince(lastCompletion)) - if sinceCompletion < eventStoreChangeNoiseInterval { - logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); \(sinceCompletion, format: .fixed(precision: 2))s since completion") - return - } - } - - if reason == .eventStoreChange, - let suppressionDeadline = reminderCompletionSuppressionDeadline, - date <= suppressionDeadline { - let remaining = suppressionDeadline.timeIntervalSince(date) - logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); reminder completion suppression \(remaining, format: .fixed(precision: 2))s") - return - } - - if reason == .eventStoreChange, - let noopDate = lastNoopEventStoreRefreshDate, - date.timeIntervalSince(noopDate) < noopEventStoreSuppressionInterval { - let remaining = noopEventStoreSuppressionInterval - date.timeIntervalSince(noopDate) - logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); identical upcoming entries \(remaining, format: .fixed(precision: 2))s window") - return - } - - if let lastForce = lastEventStoreForceRefreshDate { - let sinceForce = date.timeIntervalSince(lastForce) - if sinceForce < self.eventStoreForceThrottleInterval { - logger.debug("[Reminder] Ignoring \(reason.rawValue, privacy: .public); throttle window remaining \(self.eventStoreForceThrottleInterval - sinceForce, format: .fixed(precision: 2))s") - return - } - } - - if refreshTask != nil { - pendingEventStoreChange = (date, reason) - logger.debug("[Reminder] Queued \(reason.rawValue, privacy: .public) change while refresh is running") - return - } - - triggerEventStoreForcedRefresh(at: date, reason: reason) - } - - private func processPendingEventStoreChangeIfNeeded() { - guard let pendingChange = pendingEventStoreChange else { return } - pendingEventStoreChange = nil - processEventStoreChange(at: pendingChange.date, reason: pendingChange.reason) - } - - private func triggerEventStoreForcedRefresh(at date: Date = Date(), reason: RefreshReason) { - lastEventStoreForceRefreshDate = date - if reason == .eventStoreChange, reminderCompletionSuppressionDeadline != nil { - reminderCompletionSuppressionDeadline = nil - } - logger.debug("[Reminder] Triggering event-store refresh for \(reason.rawValue, privacy: .public)") - scheduleRefresh(force: true, reason: reason) - } - static func additionalHeight(forRowCount rowCount: Int) -> CGFloat { guard rowCount > 0 else { return 0 } let rows = CGFloat(rowCount) diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 59eaff6..97a06ee 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -503,6 +503,8 @@ extension Defaults.Keys { // MARK: Calendar static let calendarSelectionState = Key("calendarSelectionState", default: .all) + static let showFullEventTitles = Key("showFullEventTitles", default: false) + static let autoScrollToNextEvent = Key("autoScrollToNextEvent", default: true) // MARK: Fullscreen Media Detection static let alwaysHideInFullscreen = Key("alwaysHideInFullscreen", default: false)