diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index d208594..64b476b 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -511,7 +511,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.7; + MARKETING_VERSION = 2.8; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -555,7 +555,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.7; + MARKETING_VERSION = 2.8; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 95c7ed2..ed2592d 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -438,12 +438,31 @@ class CalendarStore { /// covers the worst-case month grid (6 rows × 7 cols) for the calendar /// widget. Also asks the system to refresh the widget timeline. private func publishWidgetSnapshot() { + // Never write group-view data into the widget; it would show other + // people's calendar colours and decorated event titles. + guard activeGroup == nil else { return } + let cal = userCalendar let now = Date() // Include the week before today so widgets that show the current week // (e.g. "This Week", "Up Next + Calendar") have data for Monday–today. let from = cal.date(byAdding: .day, value: -7, to: cal.startOfDay(for: now)) ?? now - let to = cal.date(byAdding: .day, value: 42, to: cal.startOfDay(for: now)) ?? from + let to = cal.date(byAdding: .day, value: 42, to: cal.startOfDay(for: now)) ?? from + + // Build the calendar list from all cached events (not just the window) + // so every calendar appears in the widget configuration picker. + var calendarMap: [String: WidgetCalendar] = [:] + for ev in allCachedEvents { + let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId) + guard !banishedCalendarKeys.contains(key) else { continue } + if calendarMap[key] == nil { + calendarMap[key] = WidgetCalendar(id: key, + name: ev.calendarName.isEmpty ? ev.calendarId : ev.calendarName, + colorHex: ev.calendarColor) + } + } + WidgetStore.writeCalendars(Array(calendarMap.values).sorted { $0.name < $1.name }) + let visible = allCachedEvents .filter { ev in let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId) @@ -460,7 +479,8 @@ class CalendarStore { end: ev.endDate, isAllDay: ev.isAllDay, colorHex: ev.effectiveColor, - location: ev.location) + location: ev.location, + calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId)) } let defaults = UserDefaults.standard let snap = WidgetSnapshot( diff --git a/CalendarrWidgets/CalendarIntent.swift b/CalendarrWidgets/CalendarIntent.swift new file mode 100644 index 0000000..6c27d8a --- /dev/null +++ b/CalendarrWidgets/CalendarIntent.swift @@ -0,0 +1,42 @@ +import AppIntents +import WidgetKit + +/// An individual calendar option shown in the widget configuration picker. +struct CalendarAppEntity: AppEntity, Identifiable { + let id: String + let name: String + let colorHex: String + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Kalender" + static var defaultQuery = CalendarEntityQuery() + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: LocalizedStringResource(stringLiteral: name)) + } +} + +/// Reads available calendars from the App Group container so the widget +/// extension can offer options without a network call. +struct CalendarEntityQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [CalendarAppEntity] { + let ids = Set(identifiers) + return WidgetStore.readCalendars() + .filter { ids.contains($0.id) } + .map { CalendarAppEntity(id: $0.id, name: $0.name, colorHex: $0.colorHex) } + } + + func suggestedEntities() async throws -> [CalendarAppEntity] { + WidgetStore.readCalendars() + .map { CalendarAppEntity(id: $0.id, name: $0.name, colorHex: $0.colorHex) } + } +} + +/// Widget configuration intent: lets the user pick which calendars to show. +/// An empty selection means "show all calendars" (the default). +struct CalendarSelectionIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Kalender auswählen" + static var description = IntentDescription("Wähle welche Kalender im Widget angezeigt werden. Leer = alle Kalender.") + + @Parameter(title: "Kalender") + var selectedCalendars: [CalendarAppEntity] +} diff --git a/CalendarrWidgets/CalendarrTimelineProvider.swift b/CalendarrWidgets/CalendarrTimelineProvider.swift index a3e52a2..fcb6902 100644 --- a/CalendarrWidgets/CalendarrTimelineProvider.swift +++ b/CalendarrWidgets/CalendarrTimelineProvider.swift @@ -1,33 +1,57 @@ import WidgetKit +import AppIntents struct CalendarrEntry: TimelineEntry { let date: Date let snapshot: WidgetSnapshot? } -struct CalendarrTimelineProvider: TimelineProvider { +struct CalendarrTimelineProvider: AppIntentTimelineProvider { + typealias Entry = CalendarrEntry + typealias Intent = CalendarSelectionIntent + func placeholder(in context: Context) -> CalendarrEntry { CalendarrEntry(date: .now, snapshot: WidgetStore.read()) } - func getSnapshot(in context: Context, completion: @escaping (CalendarrEntry) -> Void) { - completion(CalendarrEntry(date: .now, snapshot: WidgetStore.read())) + func snapshot(for configuration: CalendarSelectionIntent, in context: Context) async -> CalendarrEntry { + let raw = WidgetStore.read() + return CalendarrEntry(date: .now, snapshot: filtered(raw, by: configuration)) } - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let snapshot = WidgetStore.read() + func timeline(for configuration: CalendarSelectionIntent, in context: Context) async -> Timeline { + let raw = WidgetStore.read() + let snap = filtered(raw, by: configuration) let now = Date() - // Provide one entry per hour for the next 24h so the widget keeps - // re-rendering as time progresses (past events drop off, "now" advances). + // One entry per hour for 24 h so the widget re-renders as time advances. var entries: [CalendarrEntry] = [] for h in 0..<24 { let date = Calendar.current.date(byAdding: .hour, value: h, to: now) ?? now - entries.append(CalendarrEntry(date: date, snapshot: snapshot)) + entries.append(CalendarrEntry(date: date, snapshot: snap)) } - // Ask iOS to refresh in 30 min to pick up any new data the app wrote. let refreshAt = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now - completion(Timeline(entries: entries, policy: .after(refreshAt))) + return Timeline(entries: entries, policy: .after(refreshAt)) + } + + // MARK: – Filtering + + private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? { + guard let snapshot else { return nil } + let ids = config.selectedCalendars.map { $0.id } + guard !ids.isEmpty else { return snapshot } // empty = show all + let keep = Set(ids) + return WidgetSnapshot( + writtenAt: snapshot.writtenAt, + events: snapshot.events.filter { keep.contains($0.calendarKey) }, + todayColorHex: snapshot.todayColorHex, + textColorHex: snapshot.textColorHex, + backgroundColorHex: snapshot.backgroundColorHex, + lineColorHex: snapshot.lineColorHex, + primaryColorHex: snapshot.primaryColorHex, + accentColorHex: snapshot.accentColorHex, + language: snapshot.language + ) } } diff --git a/CalendarrWidgets/CalendarrWidgets.swift b/CalendarrWidgets/CalendarrWidgets.swift index 8cb6cb9..6cfed64 100644 --- a/CalendarrWidgets/CalendarrWidgets.swift +++ b/CalendarrWidgets/CalendarrWidgets.swift @@ -47,7 +47,7 @@ struct TodayWidget: Widget { let kind: String = "TodayWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in TodayWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.today_title", "system")) @@ -62,7 +62,7 @@ struct TwoDaysWidget: Widget { let kind: String = "TwoDaysWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in TwoDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.days_title", "system")) @@ -77,7 +77,7 @@ struct ThreeDaysWidget: Widget { let kind: String = "ThreeDaysWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in ThreeDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.threedays_title", "system")) @@ -92,7 +92,7 @@ struct ThisWeekWidget: Widget { let kind: String = "ThisWeekWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in ThisWeekWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.thisweek_title", "system")) @@ -107,7 +107,7 @@ struct TwoWeeksWidget: Widget { let kind: String = "TwoWeeksWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in TwoWeeksWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.twoweeks_title", "system")) @@ -122,7 +122,7 @@ struct UpcomingWidget: Widget { let kind: String = "UpcomingWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in UpcomingWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.upcoming_title", "system")) @@ -137,7 +137,7 @@ struct UpNextWidget: Widget { let kind: String = "UpNextWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system")) @@ -152,7 +152,7 @@ struct CalendarDayWidget: Widget { let kind: String = "CalendarDayWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system")) @@ -167,7 +167,7 @@ struct TwoMonthWidget: Widget { let kind: String = "TwoMonthWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in TwoMonthWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.twomonth_title", "system")) @@ -182,7 +182,7 @@ struct NowNextEventsWidget: Widget { let kind: String = "NowNextEventsWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in NowNextWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.nownext_title", "system")) @@ -197,7 +197,7 @@ struct LockScreenWidget: Widget { let kind: String = "LockScreenWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in LockScreenWidgetView(entry: entry) .containerBackground(for: .widget) { Color.clear } .environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system")) @@ -214,7 +214,7 @@ struct LockScreenCountWidget: Widget { let kind: String = "LockScreenCountWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in LockScreenCountWidgetView(entry: entry) .containerBackground(for: .widget) { Color.clear } .environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system")) @@ -231,7 +231,7 @@ struct LockScreenCountdownWidget: Widget { let kind: String = "LockScreenCountdownWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in LockScreenCountdownWidgetView(entry: entry) .containerBackground(for: .widget) { Color.clear } .environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system")) diff --git a/Shared/WidgetData.swift b/Shared/WidgetData.swift index 7fba69b..a623aed 100644 --- a/Shared/WidgetData.swift +++ b/Shared/WidgetData.swift @@ -8,6 +8,14 @@ import WidgetKit /// and the App Group ID registered in the Apple Developer Portal. let widgetAppGroupID = "group.com.scarriffleservices.calendarr" +/// Lightweight calendar descriptor stored alongside the event cache so the +/// widget configuration intent can offer calendar options without a network call. +struct WidgetCalendar: Codable, Identifiable, Hashable { + let id: String // calendarKey ("source-calendarId") + let name: String + let colorHex: String +} + /// Lightweight event representation that lives inside the widget cache. /// We strip everything the widget doesn't need (notes, calendar IDs, URLs). struct WidgetEvent: Codable, Hashable, Identifiable { @@ -18,6 +26,27 @@ struct WidgetEvent: Codable, Hashable, Identifiable { let isAllDay: Bool let colorHex: String let location: String + let calendarKey: String // used for per-calendar filtering in widget config + + init(id: String, title: String, start: Date, end: Date, + isAllDay: Bool, colorHex: String, location: String, calendarKey: String) { + self.id = id; self.title = title; self.start = start; self.end = end + self.isAllDay = isAllDay; self.colorHex = colorHex + self.location = location; self.calendarKey = calendarKey + } + + // Backward-compatible decoder: old caches have no calendarKey field. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + title = try c.decode(String.self, forKey: .title) + start = try c.decode(Date.self, forKey: .start) + end = try c.decode(Date.self, forKey: .end) + isAllDay = try c.decode(Bool.self, forKey: .isAllDay) + colorHex = try c.decode(String.self, forKey: .colorHex) + location = try c.decode(String.self, forKey: .location) + calendarKey = try c.decodeIfPresent(String.self, forKey: .calendarKey) ?? "" + } } /// Snapshot blob the app writes to the App-Group container and the widget reads. @@ -75,7 +104,8 @@ struct WidgetSnapshot: Codable { } enum WidgetStore { - private static let cacheFilename = "widget-cache.json" + private static let cacheFilename = "widget-cache.json" + private static let calendarsFilename = "widget-calendars.json" private static var containerURL: URL? { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID) @@ -85,6 +115,10 @@ enum WidgetStore { containerURL?.appendingPathComponent(cacheFilename) } + private static var calendarsURL: URL? { + containerURL?.appendingPathComponent(calendarsFilename) + } + /// Called by the app whenever the event cache changes. static func write(_ snapshot: WidgetSnapshot) { guard let url = cacheURL else { return } @@ -103,6 +137,21 @@ enum WidgetStore { return try? decoder.decode(WidgetSnapshot.self, from: data) } + /// Write the list of available calendars so the widget intent can offer + /// calendar options for filtering without a network call. + static func writeCalendars(_ calendars: [WidgetCalendar]) { + guard let url = calendarsURL else { return } + if let data = try? JSONEncoder().encode(calendars) { + try? data.write(to: url, options: .atomic) + } + } + + /// Read the calendar list written by the main app. + static func readCalendars() -> [WidgetCalendar] { + guard let url = calendarsURL, let data = try? Data(contentsOf: url) else { return [] } + return (try? JSONDecoder().decode([WidgetCalendar].self, from: data)) ?? [] + } + /// Rewrite the existing snapshot with the latest colour / language values /// from UserDefaults. Used when the user tweaks an appearance setting and /// we want the widgets to refresh immediately, without needing a new event