diff --git a/ios/Calendarr iOS.xcodeproj/project.xcworkspace/xcuserdata/scarriffle.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Calendarr iOS.xcodeproj/project.xcworkspace/xcuserdata/scarriffle.xcuserdatad/UserInterfaceState.xcuserstate index 2e585ff..e2faf84 100644 Binary files a/ios/Calendarr iOS.xcodeproj/project.xcworkspace/xcuserdata/scarriffle.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Calendarr iOS.xcodeproj/project.xcworkspace/xcuserdata/scarriffle.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Calendarr iOS/Models/CalendarStore.swift b/ios/Calendarr iOS/Models/CalendarStore.swift index 35a0c28..ddb9e8d 100644 --- a/ios/Calendarr iOS/Models/CalendarStore.swift +++ b/ios/Calendarr iOS/Models/CalendarStore.swift @@ -1,6 +1,8 @@ import Foundation import SwiftUI import CalendarrCore +// loadWritableCalendars reads EKCalendar.title/.calendarIdentifier directly. +import EventKit extension Notification.Name { /// Posted whenever the persistent "banished calendars" set is mutated from diff --git a/ios/Calendarr iOS/Services/CalendarrAPI.swift b/ios/Calendarr iOS/Services/CalendarrAPI.swift index a1f99c8..e4f4392 100644 --- a/ios/Calendarr iOS/Services/CalendarrAPI.swift +++ b/ios/Calendarr iOS/Services/CalendarrAPI.swift @@ -532,7 +532,7 @@ class CalendarrAPI { case "caldav": path = "/api/caldav/calendars/\(calendarId)" case "google": path = "/api/google/calendars/\(calendarId)" case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)" - case "local": path = "/api/local/calendars/\(calendarId)" + case "local": path = "/api/local/calendars/\(calendarId)/reminders" case "ical": path = "/api/ical/subscriptions/\(calendarId)" default: return } diff --git a/ios/Calendarr iOS/Services/NotificationScheduler.swift b/ios/Calendarr iOS/Services/NotificationScheduler.swift index ab4810a..b577f51 100644 --- a/ios/Calendarr iOS/Services/NotificationScheduler.swift +++ b/ios/Calendarr iOS/Services/NotificationScheduler.swift @@ -35,8 +35,13 @@ enum NotificationScheduler { let key = CalendarStore.calendarKey(source: ev.source, calendarId: ev.calendarId) if disabledCalendarKeys.contains(key) { continue } } + // Device calendars are already served by the system Calendar + // app, so applying our default here notifies twice for one + // event. They stay silent unless the event carries its own + // reminder; the per-calendar toggle can still switch them on. + let allowsDefault = defaultMin >= 0 && ev.source != "device" let offsets = ev.reminders.isEmpty - ? (defaultMin >= 0 ? [defaultMin] : []) + ? (allowsDefault ? [defaultMin] : []) : ev.reminders for m in offsets { let fire = ev.startDate.addingTimeInterval(-Double(m) * 60) diff --git a/ios/Calendarr iOS/Views/AccountsView.swift b/ios/Calendarr iOS/Views/AccountsView.swift index e1b1c00..06a572d 100644 --- a/ios/Calendarr iOS/Views/AccountsView.swift +++ b/ios/Calendarr iOS/Views/AccountsView.swift @@ -1,5 +1,6 @@ import SwiftUI import UniformTypeIdentifiers +import EventKit struct AccountsView: View { let api: CalendarrAPI diff --git a/ios/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/ios/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index faa52e8..1ea932a 100644 --- a/ios/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/ios/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import EventKit /// `.sheet(item:)` needs Identifiable and URL is not. A retroactive conformance /// on a stdlib type would be visible module-wide, so wrap it instead. diff --git a/ios/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/ios/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index 01a11dc..dec5171 100644 --- a/ios/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/ios/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -1,5 +1,6 @@ import SwiftUI import UniformTypeIdentifiers +import EventKit struct EventEditorSheet: View { let api: CalendarrAPI diff --git a/ios/Calendarr iOS/Views/CalendarFilterContent.swift b/ios/Calendarr iOS/Views/CalendarFilterContent.swift index e6df0c6..ec276a2 100644 --- a/ios/Calendarr iOS/Views/CalendarFilterContent.swift +++ b/ios/Calendarr iOS/Views/CalendarFilterContent.swift @@ -1,4 +1,5 @@ import SwiftUI +import EventKit /// A single calendar row in the flat, reorderable list. private struct CalRow: Identifiable { @@ -87,39 +88,44 @@ struct CalendarFilterContent: View { @ViewBuilder private func row(_ cal: CalRow) -> some View { let isVisible = !hidden.contains(cal.id) - Button { - if isVisible { hidden.insert(cal.id) } else { hidden.remove(cal.id) } - store.setCalendarHidden(cal.id, hidden: isVisible) - } label: { - HStack(spacing: 12) { - Circle() - .fill(Color(hex: cal.colorHex)) - .frame(width: 14, height: 14) - .opacity(isVisible ? 1.0 : 0.35) - Text(cal.name) - .foregroundStyle(isVisible ? .primary : .secondary) - .strikethrough(!isVisible, color: .secondary) - if cal.readOnly { - Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary) + let muted = reminderDisabled.contains(cal.id) + HStack(spacing: 8) { + Button { + if isVisible { hidden.insert(cal.id) } else { hidden.remove(cal.id) } + store.setCalendarHidden(cal.id, hidden: isVisible) + } label: { + HStack(spacing: 12) { + Circle() + .fill(Color(hex: cal.colorHex)) + .frame(width: 14, height: 14) + .opacity(isVisible ? 1.0 : 0.35) + Text(cal.name) + .foregroundStyle(isVisible ? .primary : .secondary) + .strikethrough(!isVisible, color: .secondary) + if cal.readOnly { + Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: isVisible ? "eye" : "eye.slash") + .foregroundStyle(isVisible ? Color.accentColor : .secondary) } - Spacer() - if reminderDisabled.contains(cal.id) { - Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary) - } - Image(systemName: isVisible ? "eye" : "eye.slash") - .foregroundStyle(isVisible ? Color.accentColor : .secondary) + .contentShape(Rectangle()) } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .contextMenu { + .buttonStyle(.plain) + + // A visible control, the way Android already does it. The mute used + // to live only behind a long press, with no hint it existed until + // after it had been switched on. Button { toggleReminders(forKey: cal.id) } label: { - let disabled = reminderDisabled.contains(cal.id) - Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang), - systemImage: disabled ? "bell" : "bell.slash") + Image(systemName: muted ? "bell.slash" : "bell") + .foregroundStyle(muted ? .secondary : Color.accentColor) } + .buttonStyle(.borderless) + .accessibilityLabel(L10n.t(muted ? "filter.reminders_on" : "filter.reminders_off", appLang)) + } + .contextMenu { Button(role: .destructive) { hidden.remove(cal.id) banished.insert(cal.id) @@ -226,8 +232,16 @@ struct CalendarFilterContent: View { for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) } for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } } for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } } - store.setReminderDisabledKeys(rd) - reminderDisabled = rd + // Only server-backed sources are reconciled here. Replacing the whole + // set would drop every device-calendar mute — and the mute of any + // calendar the server happened not to return on this call. + let serverSources: Set = ["local", "caldav", "ical", "google", "homeassistant"] + let keptLocally = store.reminderDisabledKeys.filter { key in + !serverSources.contains(key.split(separator: ":").first.map(String.init) ?? "") + } + let merged = rd.union(keptLocally) + store.setReminderDisabledKeys(merged) + reminderDisabled = merged // Build the flat row list (banished excluded), then sort by stored order. var combined: [CalRow] = []