| 1 | import Combine |
| 2 | import Foundation |
| 3 | import RastrilloApple |
| 4 | |
| 5 | @MainActor public final class CalendarModel: ObservableObject { |
| 6 | @Published public var date = Date() |
| 7 | @Published public var selectedCalendar: Int64? |
| 8 | @Published public private(set) var calendars: [TeamCalendar] = [] |
| 9 | @Published public private(set) var events: [CalendarEvent] = [] |
| 10 | @Published public private(set) var zone = "UTC" |
| 11 | @Published public private(set) var loading = false |
| 12 | @Published public var error = "" |
| 13 | /// True once a load has succeeded in this session. A later failure |
| 14 | /// then keeps what is on screen and reports the error beside it: a |
| 15 | /// network blip must not blank a calendar that was showing a second |
| 16 | /// ago. Only a session that has never loaded shows the error alone. |
| 17 | @Published public private(set) var hasLoaded = false |
| 18 | @Published public private(set) var notice = "" |
| 19 | private var weekStart = 1 |
| 20 | private var defaultCalendar: Int64 = 0 |
| 21 | private var generation = UUID() |
| 22 | private var active = true |
| 23 | @Published var quickText = "" |
| 24 | @Published var quickSavedNotice = "" |
| 25 | @Published private(set) var quickSaving = false |
| 26 | private let fixedZone: String? |
| 27 | public static var preferredZone: String { |
| 28 | let override = UserDefaults.standard.string(forKey: "calendar.timeZoneOverride") ?? "" |
| 29 | return TimeZone(identifier: override)?.identifier ?? TimeZone.current.identifier |
| 30 | } |
| 31 | public init(zone: String? = nil, date: Date = Date()) { |
| 32 | fixedZone = zone; self.zone = zone ?? Self.preferredZone; self.date = date |
| 33 | } |
| 34 | public var calendar: Calendar { |
| 35 | var c = Calendar(identifier: .gregorian) |
| 36 | c.timeZone = TimeZone(identifier: zone) ?? .gmt |
| 37 | c.firstWeekday = weekStart % 7 + 1 |
| 38 | return c |
| 39 | } |
| 40 | public var weekdayLabels: [String] { |
| 41 | let symbols = calendar.shortStandaloneWeekdaySymbols |
| 42 | return (0..<7).map { symbols[(calendar.firstWeekday - 1 + $0) % 7] } |
| 43 | } |
| 44 | public var monthStart: Date { calendar.date(from: calendar.dateComponents([.year, .month], from: date)) ?? date } |
| 45 | public var days: [Date] { |
| 46 | let first = monthStart |
| 47 | let offset = (calendar.component(.weekday, from: first) - calendar.firstWeekday + 7) % 7 |
| 48 | let start = calendar.date(byAdding: .day, value: -offset, to: first) ?? first |
| 49 | return (0..<42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) } |
| 50 | } |
| 51 | public func clear() { |
| 52 | active = false; quickText = ""; quickSavedNotice = ""; quickSaving = false |
| 53 | generation = UUID(); calendars = []; events = []; hasLoaded = false; notice = ""; error = ""; loading = false |
| 54 | selectedCalendar = nil; date = Date(); zone = fixedZone ?? Self.preferredZone |
| 55 | } |
| 56 | public func dayEvents(_ day: Date) -> [CalendarEvent] { |
| 57 | events.filter { (selectedCalendar == nil || $0.calendar_id == selectedCalendar) && $0.occurs(on: day, calendar: calendar) } |
| 58 | } |
| 59 | public func load(_ client: NativeHTTP) async { |
| 60 | guard active else { return } |
| 61 | zone = fixedZone ?? Self.preferredZone |
| 62 | let token = UUID(); generation = token |
| 63 | loading = true; error = "" |
| 64 | defer { if generation == token { loading = false } } |
| 65 | do { |
| 66 | let session: CalendarSession = try await client.request("/api/native/session") |
| 67 | guard generation == token else { return } |
| 68 | weekStart = session.week_start; calendars = session.calendars; defaultCalendar = session.default_calendar_id |
| 69 | let from = format(days.first ?? monthStart, allDay: true) |
| 70 | let to = format(calendar.date(byAdding: .day, value: 1, to: days.last ?? monthStart)!, allDay: true) |
| 71 | let response: CalendarEvents = try await client.request("/api/native/events?from=\(from)&to=\(to)&zone=\(zone.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? zone)") |
| 72 | guard generation == token else { return } |
| 73 | events = response.events |
| 74 | hasLoaded = true |
| 75 | } catch { |
| 76 | // Keep the last good calendars and events: they are stale, not |
| 77 | // wrong, and the error says so beside them. |
| 78 | if generation == token { self.error = error.localizedDescription } |
| 79 | } |
| 80 | } |
| 81 | public func newEvent() -> CalendarEvent { |
| 82 | var event = CalendarEvent() |
| 83 | event.calendar_id = calendars.first(where: { $0.id == selectedCalendar && $0.writable })?.id ?? defaultCalendar |
| 84 | event.tz = zone |
| 85 | let day = calendar.startOfDay(for: date) |
| 86 | let start = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: day) ?? day |
| 87 | event.start_local = format(start, allDay: false) |
| 88 | event.end_local = format(start.addingTimeInterval(3600), allDay: false) |
| 89 | return event |
| 90 | } |
| 91 | public func save(_ event: CalendarEvent, client: NativeHTTP) async throws { |
| 92 | guard active else { throw CancellationError() } |
| 93 | let saved: SavedEvent = try await client.request(event.id == 0 ? "/api/native/events" : "/api/native/events/\(event.id)", method: "POST", body: JSONEncoder().encode(event)) |
| 94 | guard active else { throw CancellationError() } |
| 95 | await load(client) |
| 96 | guard active else { throw CancellationError() } |
| 97 | notice = saved.warning ?? "" |
| 98 | } |
| 99 | func saveQuick(_ preview: QuickPreview, client: NativeHTTP) async throws { |
| 100 | guard active, !quickSaving else { throw CancellationError() } |
| 101 | quickSaving = true |
| 102 | defer { if active { quickSaving = false } } |
| 103 | let result: SavedEvent = try await client.request("/api/native/quick", method: "POST", body: JSONEncoder().encode(preview.draft)) |
| 104 | guard active else { throw CancellationError() } |
| 105 | quickText = "" |
| 106 | quickSavedNotice = result.warning ?? "Added \(preview.draft.title)." |
| 107 | notice = quickSavedNotice |
| 108 | if let date = parse(preview.draft.start_local, allDay: preview.draft.all_day, zone: preview.draft.tz) { self.date = date } |
| 109 | await load(client) |
| 110 | } |
| 111 | public func format(_ date: Date, allDay: Bool, zone: String? = nil) -> String { |
| 112 | let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX") |
| 113 | f.timeZone = TimeZone(identifier: zone ?? self.zone) ?? .gmt |
| 114 | f.dateFormat = allDay ? "yyyy-MM-dd" : "yyyy-MM-dd'T'HH:mm" |
| 115 | return f.string(from: date) |
| 116 | } |
| 117 | public func display(_ date: Date, template: String, zone: String? = nil) -> String { |
| 118 | let f = DateFormatter() |
| 119 | f.locale = .current |
| 120 | f.timeZone = TimeZone(identifier: zone ?? self.zone) ?? .gmt |
| 121 | f.setLocalizedDateFormatFromTemplate(template) |
| 122 | return f.string(from: date) |
| 123 | } |
| 124 | public func parse(_ value: String, allDay: Bool, zone: String) -> Date? { |
| 125 | let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX") |
| 126 | f.timeZone = TimeZone(identifier: zone) ?? .gmt |
| 127 | f.dateFormat = allDay ? "yyyy-MM-dd" : "yyyy-MM-dd'T'HH:mm" |
| 128 | return f.date(from: value) |
| 129 | } |
| 130 | } |
| 131 | |