mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
BugFix: Reminder Memory Leak
This commit is contained in:
parent
4e0659d4cc
commit
39673e9130
8 changed files with 401 additions and 456 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 + 2 * spacerNum + 1, id: \.self) { index in
|
||||
if(index < spacerNum || index > totalSteps + spacerNum - 1){
|
||||
Spacer().frame(width: 24, height: 24).id(index)
|
||||
let dateCount = totalDateItems()
|
||||
let totalItems = dateCount + 2 * spacerNum
|
||||
ForEach(0..<totalItems, id: \.self) { index in
|
||||
if index < spacerNum || index >= 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
16
DynamicIsland/extensions/Color+AccentColor.swift
Normal file
16
DynamicIsland/extensions/Color+AccentColor.swift
Normal file
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AnyCancellable>()
|
||||
private var tickerTask: Task<Void, Never>? { didSet { oldValue?.cancel() } }
|
||||
private var evaluationTask: Task<Void, Never>?
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private var pendingRefreshTask: Task<Void, Never>?
|
||||
private var pendingRefreshForce = false
|
||||
private var pendingRefreshToken = UUID()
|
||||
private var pendingRefreshReason: RefreshReason = .other
|
||||
private var lastKnownCalendarIDs = Set<String>()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -503,6 +503,8 @@ extension Defaults.Keys {
|
|||
|
||||
// MARK: Calendar
|
||||
static let calendarSelectionState = Key<CalendarSelectionState>("calendarSelectionState", default: .all)
|
||||
static let showFullEventTitles = Key<Bool>("showFullEventTitles", default: false)
|
||||
static let autoScrollToNextEvent = Key<Bool>("autoScrollToNextEvent", default: true)
|
||||
|
||||
// MARK: Fullscreen Media Detection
|
||||
static let alwaysHideInFullscreen = Key<Bool>("alwaysHideInFullscreen", default: false)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue