diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj
index 705cf89..dc282ad 100644
--- a/Calendarr iOS.xcodeproj/project.pbxproj
+++ b/Calendarr iOS.xcodeproj/project.pbxproj
@@ -8,6 +8,8 @@
/* Begin PBXBuildFile section */
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; };
+ CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -95,6 +97,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -102,6 +105,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -175,6 +179,7 @@
);
name = "Calendarr iOS";
packageProductDependencies = (
+ CA1E4D0A0001000000000001 /* CalendarrCore */,
);
productName = "Calendarr iOS";
productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */;
@@ -198,6 +203,7 @@
);
name = CalendarrWidgets;
packageProductDependencies = (
+ CA1E4D0A0001000000000002 /* CalendarrCore */,
);
productName = CalendarrWidgets;
productReference = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */;
@@ -235,6 +241,9 @@
);
mainGroup = C0000201FB4E10100AB5001;
minimizedProjectReferenceProxies = 1;
+ packageReferences = (
+ 39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */,
+ );
preferredProjectObjectVersion = 77;
productRefGroup = C0000C01FB4E10100AB5001 /* Products */;
projectDirPath = "";
@@ -493,9 +502,9 @@
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = PP34X97WS3;
+ ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
- ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
@@ -538,9 +547,9 @@
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = PP34X97WS3;
+ ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
- ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
@@ -683,6 +692,24 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ CA1E4D0A0001000000000001 /* CalendarrCore */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = CalendarrCore;
+ };
+ CA1E4D0A0001000000000002 /* CalendarrCore */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = CalendarrCore;
+ };
+/* End XCSwiftPackageProductDependency section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../CalendarrKit;
+ };
+/* End XCLocalSwiftPackageReference section */
};
rootObject = C0000301FB4E10100AB5001 /* Project object */;
}
diff --git a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist
index c7f1ae5..af12f91 100644
--- a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -12,7 +12,7 @@
CalendarrWidgets.xcscheme_^#shared#^_
orderHint
- 1
+ 2
SuppressBuildableAutocreation
diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift
index ede36bf..904c13b 100644
--- a/Calendarr iOS/CalendarrApp.swift
+++ b/Calendarr iOS/CalendarrApp.swift
@@ -76,6 +76,17 @@ class AppState {
try? KeychainStore.set(token, for: "authToken") // secret → Keychain, not UserDefaults
UserDefaults.standard.set(user, forKey: "username")
UserDefaults.standard.set(admin, forKey: "isAdmin")
+ publishSession()
+ }
+
+ /// Mirror the non-secret session facts into the shared container. Apps in
+ /// another sandbox cannot read our UserDefaults, so this is how they learn
+ /// which server we point at and whether anyone is signed in. The token
+ /// itself stays in the shared keychain group, never in a plain file.
+ private func publishSession() {
+ WidgetStore.writeSession(baseURL: serverURL,
+ username: username,
+ isLoggedIn: isLoggedIn)
}
func logout() {
@@ -89,12 +100,18 @@ class AppState {
UserDefaults.standard.removeObject(forKey: "isAdmin")
// The shared container outlives the session, so it has to be cleared
// explicitly — otherwise widgets keep showing the signed-out user's data.
+ // The session record stays behind, flagged signed-out, so a reader can
+ // say "sign in to Calendarr" rather than "open Calendarr once".
WidgetStore.clear()
+ publishSession()
}
func resetServer() {
logout()
serverURL = ""
UserDefaults.standard.removeObject(forKey: "serverURL")
+ // Back to unconfigured: there is no server left to name, so drop the
+ // session record rather than leaving a stale URL in the container.
+ WidgetStore.clearSession()
}
}
diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift
index f1a723b..05e68e4 100644
--- a/Calendarr iOS/Models/CalendarStore.swift
+++ b/Calendarr iOS/Models/CalendarStore.swift
@@ -1,5 +1,6 @@
import Foundation
import SwiftUI
+import CalendarrCore
extension Notification.Name {
/// Posted whenever the persistent "banished calendars" set is mutated from
@@ -579,8 +580,12 @@ class CalendarStore {
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
+ // The window is published in the snapshot as coverageStart/coverageEnd:
+ // outside it a reader has no information, which is not the same as
+ // having no events, and only the writer knows where the edge is.
+ let dayStart = cal.startOfDay(for: now)
+ let from = cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart) ?? now
+ let to = cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart) ?? from
// Build the calendar list from all cached events (not just the window)
// so every calendar appears in the widget configuration picker.
@@ -604,7 +609,7 @@ class CalendarStore {
&& !banishedCalendarKeys.contains(key)
}
.sorted { $0.startDate < $1.startDate }
- .prefix(500)
+ .prefix(SnapshotCoverage.maxEvents)
.map { ev in
WidgetEvent(id: ev.id,
title: ev.title,
@@ -615,19 +620,9 @@ class CalendarStore {
location: ev.location,
calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId))
}
- let defaults = UserDefaults.standard
- let snap = WidgetSnapshot(
- writtenAt: now,
- events: Array(visible),
- todayColorHex: defaults.string(forKey: "todayColor") ?? "#4285f4",
- textColorHex: defaults.string(forKey: "textColor") ?? "#FFFFFF",
- backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? "#000000",
- lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A52",
- primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4",
- accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335",
- language: defaults.string(forKey: "appLanguage") ?? "system"
- )
- WidgetStore.write(snap)
+ WidgetStore.write(WidgetStore.makeSnapshot(events: Array(visible),
+ coverageStart: from,
+ coverageEnd: to))
WidgetTimelineNotifier.reload()
}
diff --git a/CalendarrWidgets/CalendarrTimelineProvider.swift b/CalendarrWidgets/CalendarrTimelineProvider.swift
index d091007..c3be55a 100644
--- a/CalendarrWidgets/CalendarrTimelineProvider.swift
+++ b/CalendarrWidgets/CalendarrTimelineProvider.swift
@@ -41,16 +41,17 @@ struct CalendarrTimelineProvider: AppIntentTimelineProvider {
let ids = (config.selectedCalendars ?? []).map { $0.id }
guard !ids.isEmpty else { return snapshot } // nothing selected = show all
let keep = Set(ids)
+ // Filtering narrows the events but not the window: the snapshot still
+ // speaks for the same range, it just has fewer calendars in it.
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
+ writtenAt: snapshot.writtenAt,
+ coverageStart: snapshot.coverageStart,
+ coverageEnd: snapshot.coverageEnd,
+ isLoggedIn: snapshot.isLoggedIn,
+ writerVersion: snapshot.writerVersion,
+ events: snapshot.events.filter { keep.contains($0.calendarKey) },
+ theme: snapshot.theme,
+ language: snapshot.language
)
}
}
diff --git a/Shared/WidgetData.swift b/Shared/WidgetData.swift
index 0a4aea0..d23de8b 100644
--- a/Shared/WidgetData.swift
+++ b/Shared/WidgetData.swift
@@ -1,221 +1,136 @@
import Foundation
+@_spi(Writer) import CalendarrCore
#if canImport(WidgetKit)
import WidgetKit
#endif
-/// App-Group identifier shared between the main app and the widget extension.
-///
-/// IMPORTANT: this must stay byte-identical to `com.apple.security.application-groups`
-/// in the matching .entitlements file for the platform being built, and match the
-/// App Group registered in the Apple Developer portal.
-///
-/// The identifier registered in the portal never changes — only the string the
-/// *runtime* expects does. macOS (including Mac Catalyst) requires the Team ID
-/// prefix; iOS forbids it. Get this wrong and `containerURL(forSecurityApplication‑
-/// GroupIdentifier:)` returns nil, every read and write below quietly no-ops, and
-/// the widgets show placeholder content forever with no error anywhere.
-enum CalendarrAppGroup {
- /// As registered in the Apple Developer portal.
- static let unprefixed = "group.com.scarriffleservices.calendarr"
- /// Team ID — the value `$(AppIdentifierPrefix)` expands to at build time.
- static let teamID = "PP34X97WS3"
-
- #if os(macOS) || targetEnvironment(macCatalyst)
- static let current = "\(teamID).\(unprefixed)"
- #else
- static let current = unprefixed
- #endif
-}
+// The snapshot types and the App Group plumbing now live in CalendarrCore, so
+// that a second app from this team reads the exact same format instead of
+// reimplementing it and drifting.
+//
+// These aliases keep the ~70 existing call sites in the app and the widget
+// compiling unchanged. They are a migration convenience, not a design: new code
+// should use the CalendarrCore names directly.
+typealias WidgetEvent = SnapshotEvent
+typealias WidgetCalendar = SnapshotCalendar
+typealias WidgetSnapshot = CalendarrSnapshot
+/// The App Group identifier for this platform. See `CalendarrAppGroup`.
let widgetAppGroupID = CalendarrAppGroup.current
-/// 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 {
- let id: String
- let title: String
- let start: Date
- let end: Date
- 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.
-struct WidgetSnapshot: Codable {
- let writtenAt: Date
- let events: [WidgetEvent]
- /// Mirrors the user's chosen visual settings so the widget looks the same
- /// as the app even when its own AppStorage in the extension is empty.
- let todayColorHex: String
- let textColorHex: String
- let backgroundColorHex: String
- let lineColorHex: String
- let primaryColorHex: String
- let accentColorHex: String
- let language: String
-
- init(writtenAt: Date,
- events: [WidgetEvent],
- todayColorHex: String,
- textColorHex: String,
- backgroundColorHex: String,
- lineColorHex: String,
- primaryColorHex: String,
- accentColorHex: String,
- language: String) {
- self.writtenAt = writtenAt
- self.events = events
- self.todayColorHex = todayColorHex
- self.textColorHex = textColorHex
- self.backgroundColorHex = backgroundColorHex
- self.lineColorHex = lineColorHex
- self.primaryColorHex = primaryColorHex
- self.accentColorHex = accentColorHex
- self.language = language
- }
-
- /// Custom decoder so older caches without the new colour fields still load.
- init(from decoder: Decoder) throws {
- let c = try decoder.container(keyedBy: CodingKeys.self)
- writtenAt = try c.decode(Date.self, forKey: .writtenAt)
- events = try c.decode([WidgetEvent].self, forKey: .events)
- todayColorHex = try c.decode(String.self, forKey: .todayColorHex)
- textColorHex = try c.decode(String.self, forKey: .textColorHex)
- backgroundColorHex = try c.decode(String.self, forKey: .backgroundColorHex)
- lineColorHex = try c.decode(String.self, forKey: .lineColorHex)
- language = try c.decode(String.self, forKey: .language)
- primaryColorHex = try c.decodeIfPresent(String.self, forKey: .primaryColorHex) ?? "#4285f4"
- accentColorHex = try c.decodeIfPresent(String.self, forKey: .accentColorHex) ?? "#ea4335"
- }
-
- private enum CodingKeys: String, CodingKey {
- case writtenAt, events, todayColorHex, textColorHex, backgroundColorHex
- case lineColorHex, primaryColorHex, accentColorHex, language
- }
+extension CalendarrSnapshot {
+ // The six colours are stored flat on the wire for backwards compatibility
+ // but exposed as `theme` in the package. These keep the widget views, which
+ // read them individually, from having to change.
+ var todayColorHex: String { theme.today }
+ var textColorHex: String { theme.text }
+ var backgroundColorHex: String { theme.background }
+ var lineColorHex: String { theme.line }
+ var primaryColorHex: String { theme.primary }
+ var accentColorHex: String { theme.accent }
}
+/// Thin facade over `CalendarrCore.SnapshotStore`, preserving the static API the
+/// app and widget already use. Errors are swallowed here exactly as before —
+/// a failed widget cache write must never interrupt the user — but the store now
+/// reports *why* a read failed, which is what `read()` discards and callers that
+/// need the distinction should use `SnapshotStore` directly for.
enum WidgetStore {
- private static let cacheFilename = "widget-cache.json"
- private static let calendarsFilename = "widget-calendars.json"
+ private static let store = SnapshotStore()
+ private static let sessions = SharedSessionStore()
- private static var containerURL: URL? {
- let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID)
- #if DEBUG
- if url == nil {
- // A nil container is always a build-configuration bug: the entitlement
- // is missing, or its value does not match widgetAppGroupID. It is
- // otherwise completely silent, so make it loud while developing.
- assertionFailure("App Group container unavailable for \(widgetAppGroupID) — "
- + "check com.apple.security.application-groups in the "
- + "entitlements for this platform.")
- }
- #endif
- return url
+ /// Version of the app writing the cache, for diagnostics in the consumer.
+ private static var writerVersion: String {
+ Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
}
- private static var cacheURL: URL? {
- 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 }
- let encoder = JSONEncoder()
- encoder.dateEncodingStrategy = .iso8601
- if let data = try? encoder.encode(snapshot) {
- try? data.write(to: url, options: .atomic)
- }
+ try? store.write(snapshot)
}
- /// Called by the widget timeline provider to load the latest snapshot.
static func read() -> WidgetSnapshot? {
- guard let url = cacheURL, let data = try? Data(contentsOf: url) else { return nil }
- let decoder = JSONDecoder()
- decoder.dateDecodingStrategy = .iso8601
- return try? decoder.decode(WidgetSnapshot.self, from: data)
+ store.read().snapshot
}
- /// 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)
- }
+ try? store.writeCalendars(calendars)
}
- /// 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)) ?? []
+ store.readCalendars()
}
- /// Drop the cached snapshot and calendar list. Called on logout and on
- /// server reset: without this the files survive, and widgets — plus any
- /// other app reading the group container — keep rendering the previous
- /// user's events indefinitely.
+ /// Record which server we are pointed at and whether anyone is signed in, so
+ /// a reading app can tell "signed out" from "never ran" without reaching into
+ /// this app's UserDefaults, which lives in another sandbox.
+ static func writeSession(baseURL: String, username: String, isLoggedIn: Bool) {
+ try? sessions.write(SharedSession(baseURL: baseURL,
+ username: username,
+ isLoggedIn: isLoggedIn,
+ writtenAt: Date()))
+ }
+
+ /// Forget the session entirely. Used on server reset, where the app returns
+ /// to unconfigured and there is no longer a server worth naming.
+ static func clearSession() {
+ sessions.clear()
+ }
+
+ /// Drop the cached calendar. Called on sign-out and server reset: the
+ /// container outlives the session, so without this every reader keeps
+ /// rendering the previous user's events.
static func clear() {
- for url in [cacheURL, calendarsURL].compactMap({ $0 }) {
- try? FileManager.default.removeItem(at: url)
- }
+ store.clear()
WidgetTimelineNotifier.reload()
}
- /// 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
- /// sync. No-op if there's no cached snapshot yet.
+ /// Rewrite the existing snapshot with the latest colour / language values so
+ /// widgets pick up an appearance change immediately, without waiting for the
+ /// next event sync. No-op when nothing is cached yet.
static func republishAppearanceOnly() {
guard let existing = read() else { return }
let defaults = UserDefaults.standard
let updated = WidgetSnapshot(
writtenAt: Date(),
+ coverageStart: existing.coverageStart,
+ coverageEnd: existing.coverageEnd,
+ isLoggedIn: existing.isLoggedIn,
+ writerVersion: writerVersion,
events: existing.events,
- todayColorHex: defaults.string(forKey: "todayColor") ?? existing.todayColorHex,
- textColorHex: defaults.string(forKey: "textColor") ?? existing.textColorHex,
- backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? existing.backgroundColorHex,
- lineColorHex: defaults.string(forKey: "lineColor") ?? existing.lineColorHex,
- primaryColorHex: defaults.string(forKey: "primaryColor") ?? existing.primaryColorHex,
- accentColorHex: defaults.string(forKey: "accentColor") ?? existing.accentColorHex,
- language: defaults.string(forKey: "appLanguage") ?? existing.language
- )
+ theme: SnapshotTheme(
+ today: defaults.string(forKey: "todayColor") ?? existing.theme.today,
+ text: defaults.string(forKey: "textColor") ?? existing.theme.text,
+ background: defaults.string(forKey: "backgroundColor") ?? existing.theme.background,
+ line: defaults.string(forKey: "lineColor") ?? existing.theme.line,
+ primary: defaults.string(forKey: "primaryColor") ?? existing.theme.primary,
+ accent: defaults.string(forKey: "accentColor") ?? existing.theme.accent),
+ language: defaults.string(forKey: "appLanguage") ?? existing.language)
write(updated)
WidgetTimelineNotifier.reload()
}
+
+ /// Build a snapshot from the app's current state. Kept here so the coverage
+ /// window and the writer version are stamped in exactly one place.
+ static func makeSnapshot(events: [WidgetEvent],
+ coverageStart: Date,
+ coverageEnd: Date) -> WidgetSnapshot {
+ let defaults = UserDefaults.standard
+ return WidgetSnapshot(
+ writtenAt: Date(),
+ coverageStart: coverageStart,
+ coverageEnd: coverageEnd,
+ isLoggedIn: true, // only ever written while signed in
+ writerVersion: writerVersion,
+ events: events,
+ theme: SnapshotTheme(
+ today: defaults.string(forKey: "todayColor") ?? "#4285f4",
+ text: defaults.string(forKey: "textColor") ?? "#FFFFFF",
+ background: defaults.string(forKey: "backgroundColor") ?? "#000000",
+ line: defaults.string(forKey: "lineColor") ?? "#3A3A52",
+ primary: defaults.string(forKey: "primaryColor") ?? "#4285f4",
+ accent: defaults.string(forKey: "accentColor") ?? "#ea4335"),
+ language: defaults.string(forKey: "appLanguage") ?? "system")
+ }
}
enum WidgetTimelineNotifier {