408 lines
16 KiB
Swift
408 lines
16 KiB
Swift
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 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 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)
|
||
}
|
||
}
|
||
}
|