feat(widget): calendar filter config + fix group-view data leak

Bug fix: publishWidgetSnapshot() now guards against activeGroup != nil,
so group view events/colors never contaminate the widget cache.

Feature: widgets can now be configured via long-press → Edit Widget.
- WidgetCalendar struct + writeCalendars/readCalendars in WidgetData.swift
- calendarKey added to WidgetEvent (backward-compatible decoder)
- CalendarIntent.swift: CalendarAppEntity + CalendarEntityQuery + CalendarSelectionIntent
- CalendarrTimelineProvider migrated from TimelineProvider to AppIntentTimelineProvider
- All 13 StaticConfiguration widgets changed to AppIntentConfiguration
- publishWidgetSnapshot() builds + writes the calendar list for the intent

An empty selection (default) shows all calendars; selecting specific
calendars filters the widget events accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-06-17 18:41:43 +02:00
parent 52040fff53
commit 4e9ae83299
6 changed files with 163 additions and 28 deletions

View File

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