Files
Calendarr-IOS/Calendarr iOS/Models/CalendarStore.swift
2026-05-25 11:53:02 +02:00

408 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import SwiftUI
extension Notification.Name {
/// Posted whenever the persistent "banished calendars" set is mutated from
/// outside the active `CalendarStore` (e.g. by `AccountsView`). The store
/// listens for this in `CalendarHostView` and refreshes its filter.
static let banishedCalendarsChanged = Notification.Name("banishedCalendarsChanged")
}
enum CalViewType: String, CaseIterable {
case month, week, day, quarter, agenda
func label(_ lang: String) -> String {
switch self {
case .month: return L10n.t("view.month", lang)
case .week: return L10n.t("view.week", lang)
case .day: return L10n.t("view.day", lang)
case .quarter: return L10n.t("view.quarter", lang)
case .agenda: return L10n.t("view.agenda", lang)
}
}
var systemImage: String {
switch self {
case .month: return "calendar"
case .week: return "calendar.day.timeline.leading"
case .day: return "sun.max"
case .quarter: return "calendar.badge.clock"
case .agenda: return "list.bullet"
}
}
}
struct WritableCalendar: Identifiable {
let id: String
let name: String
let color: String
let source: String
let numericId: Int
}
@Observable
class CalendarStore {
// Visible state
var events: [CalEvent] = []
var viewType: CalViewType = .month
var currentDate: Date = .now
var isLoading = false
var isCachingBackground = false
var lastError: String? = nil
var weekStartsOnMonday = true
var writableCalendars: [WritableCalendar] = []
/// Set of `"source:calendarId"` keys the user has chosen to hide from the
/// calendar views. Persisted in UserDefaults as a JSON array. Events whose
/// key matches one of these are filtered out before being rendered.
var hiddenCalendarKeys: Set<String> = CalendarStore.loadHiddenKeys()
/// "Banished" calendars like `hiddenCalendarKeys` but expressing a
/// stronger user intent: the calendar should not even appear in the quick
/// show/hide list. Re-activation happens in AccountsView.
var banishedCalendarKeys: Set<String> = CalendarStore.loadBanishedKeys()
// Cache bookkeeping
private var cachedStart: Date? = nil
private var cachedEnd: Date? = nil
private var allCachedEvents: [CalEvent] = []
// MARK: Hidden-calendar persistence
private static let hiddenKeysDefaultsKey = "hiddenCalendarKeys"
private static func loadHiddenKeys() -> Set<String> {
guard let raw = UserDefaults.standard.string(forKey: hiddenKeysDefaultsKey),
let data = raw.data(using: .utf8),
let arr = try? JSONDecoder().decode([String].self, from: data)
else { return [] }
return Set(arr)
}
private func saveHiddenKeys() {
let arr = Array(hiddenCalendarKeys)
if let data = try? JSONEncoder().encode(arr),
let s = String(data: data, encoding: .utf8) {
UserDefaults.standard.set(s, forKey: Self.hiddenKeysDefaultsKey)
}
}
/// Toggle visibility of a single calendar and immediately refresh the
/// visible event list + widget snapshot.
func setCalendarHidden(_ key: String, hidden: Bool) {
if hidden { hiddenCalendarKeys.insert(key) } else { hiddenCalendarKeys.remove(key) }
saveHiddenKeys()
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
publishWidgetSnapshot()
}
/// Replace the entire set (used by the filter sheet's bulk show/hide).
func setHiddenCalendars(_ keys: Set<String>) {
hiddenCalendarKeys = keys
saveHiddenKeys()
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
publishWidgetSnapshot()
}
static func calendarKey(source: String, calendarId: String) -> String {
"\(source):\(calendarId)"
}
// MARK: Banished-calendar persistence
private static let banishedKeysDefaultsKey = "banishedCalendarKeys"
static func loadBanishedKeys() -> Set<String> {
guard let raw = UserDefaults.standard.string(forKey: banishedKeysDefaultsKey),
let data = raw.data(using: .utf8),
let arr = try? JSONDecoder().decode([String].self, from: data)
else { return [] }
return Set(arr)
}
static func saveBanishedKeys(_ keys: Set<String>) {
let arr = Array(keys)
if let data = try? JSONEncoder().encode(arr),
let s = String(data: data, encoding: .utf8) {
UserDefaults.standard.set(s, forKey: banishedKeysDefaultsKey)
}
}
/// Move a calendar to / out of the banished set. Also clears any quick
/// hidden flag for that key once banished, the dual state is redundant.
/// Posts `.banishedCalendarsChanged` so other views in the navigation
/// stack (e.g. AccountsView) stay in sync.
func setCalendarBanished(_ key: String, banished: Bool) {
if banished {
banishedCalendarKeys.insert(key)
hiddenCalendarKeys.remove(key)
} else {
banishedCalendarKeys.remove(key)
}
Self.saveBanishedKeys(banishedCalendarKeys)
saveHiddenKeys()
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
publishWidgetSnapshot()
}
/// Re-read the banished set from UserDefaults called when an external
/// view (AccountsView) mutated it. Refreshes visible events + widgets.
func syncBanishedFromDefaults() {
banishedCalendarKeys = Self.loadBanishedKeys()
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
publishWidgetSnapshot()
}
var userCalendar: Calendar {
var cal = Calendar.current
cal.firstWeekday = weekStartsOnMonday ? 2 : 1
return cal
}
// MARK: Cache helpers
func isCached(start: Date, end: Date) -> Bool {
guard let cs = cachedStart, let ce = cachedEnd else { return false }
return cs <= start && ce >= end
}
/// Fast in-memory refresh of `events` for the current visible range.
/// Call this after navigation without hitting the network.
func refreshFromCache(start: Date, end: Date) {
events = allCachedEvents.filter { ev in
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
return ev.startDate < end && ev.endDate > start
&& !hiddenCalendarKeys.contains(key)
&& !banishedCalendarKeys.contains(key)
}
}
// MARK: Network loading
/// Load events for a specific range skips network if already cached.
func loadEvents(api: CalendarrAPI, start: Date, end: Date) async {
if isCached(start: start, end: end) {
refreshFromCache(start: start, end: end)
return
}
isLoading = true
lastError = nil
defer { isLoading = false }
do {
let fetched = try await api.fetchEvents(start: start, end: end)
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
refreshFromCache(start: start, end: end)
} catch {
lastError = error.localizedDescription
}
}
/// Background prefetch for ±months around today called once on startup.
func prefetchBackground(api: CalendarrAPI, months: Int) async {
let cal = userCalendar
let now = Date()
let start = cal.date(byAdding: .month, value: -months, to: cal.startOfDay(for: now))!
let end = cal.date(byAdding: .month, value: months + 1, to: cal.startOfDay(for: now))!
guard !isCached(start: start, end: end) else { return }
isCachingBackground = true
defer { isCachingBackground = false }
do {
let fetched = try await api.fetchEvents(start: start, end: end)
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
// Refresh visible range from newly expanded cache
let (vs, ve) = rangeForCurrentView()
refreshFromCache(start: vs, end: ve)
} catch {
// Background fetch failure is silent
}
}
/// Trigger a full cache reload (e.g. when cache-range setting changes).
func invalidateCache() {
cachedStart = nil
cachedEnd = nil
allCachedEvents = []
events = []
}
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) {
// Remove old events that overlap with the newly fetched range (avoid duplicates)
let retained = allCachedEvents.filter { ev in
ev.startDate >= rangeEnd || ev.endDate <= rangeStart
}
allCachedEvents = retained + newEvents
// Extend cached range
if let cs = cachedStart, let ce = cachedEnd {
cachedStart = min(cs, rangeStart)
cachedEnd = max(ce, rangeEnd)
} else {
cachedStart = rangeStart
cachedEnd = rangeEnd
}
publishWidgetSnapshot()
}
/// Write a slim snapshot of the next ~6 weeks into the App-Group container
/// so the widget extension can render without a network call. 42 days
/// 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() {
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 Mondaytoday.
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 visible = allCachedEvents
.filter { ev in
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
return ev.startDate < to && ev.endDate > from
&& !hiddenCalendarKeys.contains(key)
&& !banishedCalendarKeys.contains(key)
}
.sorted { $0.startDate < $1.startDate }
.prefix(500)
.map { ev in
WidgetEvent(id: ev.id,
title: ev.title,
start: ev.startDate,
end: ev.endDate,
isAllDay: ev.isAllDay,
colorHex: ev.effectiveColor,
location: ev.location)
}
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") ?? "#3A3A3C",
primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4",
accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335",
language: defaults.string(forKey: "appLanguage") ?? "system"
)
WidgetStore.write(snap)
WidgetTimelineNotifier.reload()
}
// MARK: Writable calendars
func loadWritableCalendars(api: CalendarrAPI) async {
async let localCals = (try? await api.getLocalCalendars()) ?? []
async let caldavAccs = (try? await api.getCalDAVAccounts()) ?? []
async let googleCals = (try? await api.getGoogleCalendars()) ?? []
async let haCals = (try? await api.getHACalendars()) ?? []
var result: [WritableCalendar] = []
for cal in await localCals {
result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id))
}
for acc in await caldavAccs where acc.enabled {
for cal in acc.calendars ?? [] where cal.enabled {
result.append(WritableCalendar(id: "caldav-\(cal.id)", name: "\(acc.name) \(cal.name)", color: cal.color ?? acc.color, source: "caldav", numericId: cal.id))
}
}
for (email, id, name, color) in await googleCals {
result.append(WritableCalendar(id: "google-\(id)", name: "\(email) \(name)", color: color, source: "google", numericId: id))
}
for (accName, id, name, color) in await haCals {
result.append(WritableCalendar(id: "ha-\(id)", name: "\(accName) \(name)", color: color, source: "homeassistant", numericId: id))
}
writableCalendars = result
}
// MARK: Query helpers
func events(on date: Date) -> [CalEvent] {
let cal = userCalendar
let dayStart = cal.startOfDay(for: date)
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
return events.filter { ev in ev.startDate < dayEnd && ev.endDate > dayStart }
.sorted { $0.startDate < $1.startDate }
}
func events(in start: Date, end: Date) -> [CalEvent] {
events.filter { ev in ev.startDate < end && ev.endDate > start }
.sorted { $0.startDate < $1.startDate }
}
// MARK: Navigation
func moveToToday() { currentDate = .now }
func navigatePrev() {
currentDate = userCalendar.date(byAdding: navComponent, value: navAmount * -1, to: currentDate) ?? currentDate
}
func navigateNext() {
currentDate = userCalendar.date(byAdding: navComponent, value: navAmount, to: currentDate) ?? currentDate
}
private var navComponent: Calendar.Component {
switch viewType {
case .week: return .weekOfYear
case .day: return .day
default: return .month
}
}
private var navAmount: Int { viewType == .quarter ? 3 : 1 }
func rangeForCurrentView() -> (Date, Date) {
let cal = userCalendar
switch viewType {
case .month:
let start = cal.date(from: cal.dateComponents([.year, .month], from: currentDate))!
return (cal.date(byAdding: .month, value: -1, to: start)!,
cal.date(byAdding: .month, value: 2, to: start)!)
case .quarter:
let start = cal.date(from: cal.dateComponents([.year, .month], from: currentDate))!
return (start, cal.date(byAdding: .month, value: 4, to: start)!)
case .week:
let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: currentDate))!
return (weekStart, cal.date(byAdding: .day, value: 8, to: weekStart)!)
case .day:
let dayStart = cal.startOfDay(for: currentDate)
return (dayStart, cal.date(byAdding: .day, value: 1, to: dayStart)!)
case .agenda:
let start = cal.startOfDay(for: .now)
return (start, cal.date(byAdding: .day, value: 90, to: start)!)
}
}
func titleForCurrentView(language: String) -> String {
let cal = userCalendar
let loc = L10n.locale(language)
let fmt = DateFormatter(); fmt.locale = loc
switch viewType {
case .month:
fmt.dateFormat = "LLLL yyyy"
return fmt.string(from: currentDate).capitalized(with: loc)
case .quarter:
fmt.dateFormat = "LLL yyyy"
let m3 = cal.date(byAdding: .month, value: 2, to: currentDate) ?? currentDate
return "\(fmt.string(from: currentDate)) \(fmt.string(from: m3))"
case .week:
let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: currentDate))!
let weekEnd = cal.date(byAdding: .day, value: 6, to: weekStart)!
fmt.dateFormat = "d. MMM"
let ef = DateFormatter(); ef.locale = loc; ef.dateFormat = "d. MMM yyyy"
return "\(fmt.string(from: weekStart)) \(ef.string(from: weekEnd))"
case .day:
fmt.dateFormat = "EEEE, d. MMMM yyyy"
return fmt.string(from: currentDate)
case .agenda:
return L10n.t("view.agenda", language)
}
}
}