|
@@ -0,0 +1,524 @@
|
|
|
|
|
+import AppKit
|
|
|
|
|
+import Combine
|
|
|
|
|
+import EventKit
|
|
|
|
|
+import SwiftUI
|
|
|
|
|
+
|
|
|
|
|
+struct CalendarEventSnapshot: Identifiable, Hashable {
|
|
|
|
|
+ let id: String
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let start: Date
|
|
|
|
|
+ let end: Date
|
|
|
|
|
+ let isAllDay: Bool
|
|
|
|
|
+ let location: String?
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+struct CalendarAgendaDay: Identifiable, Hashable {
|
|
|
|
|
+ let id: String
|
|
|
|
|
+ let dayLabel: String
|
|
|
|
|
+ let events: [CalendarEventSnapshot]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Google Calendar desktop widget: local EventKit data + deep links into calendar.google.com.
|
|
|
|
|
+struct CalendarDesktopWidgetView: View {
|
|
|
|
|
+ let app: LauncherApp
|
|
|
|
|
+ let mode: CalendarWidgetMode
|
|
|
|
|
+ var isPreview: Bool = false
|
|
|
|
|
+
|
|
|
|
|
+ @StateObject private var model = CalendarWidgetEventsModel()
|
|
|
|
|
+
|
|
|
|
|
+ private var baseURL: URL { app.webURL ?? URL(string: "https://calendar.google.com")! }
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ Group {
|
|
|
|
|
+ switch mode {
|
|
|
|
|
+ case .compact:
|
|
|
|
|
+ compactBody
|
|
|
|
|
+ case .agenda:
|
|
|
|
|
+ agendaBody
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
|
|
|
+ .task(id: isPreview) {
|
|
|
|
|
+ await model.refresh(isPreview: isPreview)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // MARK: - Small
|
|
|
|
|
+
|
|
|
|
|
+ private var compactBody: some View {
|
|
|
|
|
+ ZStack(alignment: .topLeading) {
|
|
|
|
|
+ calendarGradientBackground
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
+ HStack(spacing: 10) {
|
|
|
|
|
+ AppIconView(app: app, size: 32, showAppBackground: false, iconPaddingFactor: 0.08)
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 2) {
|
|
|
|
|
+ Text(app.name)
|
|
|
|
|
+ .font(.system(size: 13, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.95))
|
|
|
|
|
+ Text("Today")
|
|
|
|
|
+ .font(.system(size: 10, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.65))
|
|
|
|
|
+ }
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ todayDateBlock
|
|
|
|
|
+
|
|
|
|
|
+ if model.accessDenied && !isPreview {
|
|
|
|
|
+ Text("Calendar access is off. Enable it in System Settings to see events.")
|
|
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.7))
|
|
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
+ Button(action: openPrivacyCalendars) {
|
|
|
|
|
+ Text("Open Privacy settings")
|
|
|
|
|
+ .font(.system(size: 11, weight: .bold))
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .foregroundStyle(Color(red: 0.75, green: 0.9, blue: 1))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Text("Next up")
|
|
|
|
|
+ .font(.system(size: 10, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.5))
|
|
|
|
|
+
|
|
|
|
|
+ if let ev = model.nextEvent {
|
|
|
|
|
+ nextEventSummary(ev)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Text(isPreview ? "Sample event at 2:00 PM" : "No upcoming events")
|
|
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.75))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ calendarPrimaryButton(title: "Open Calendar", systemImage: "calendar") {
|
|
|
|
|
+ openCalendarPath("/calendar/u/0/r")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(14)
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
+ .disabled(isPreview)
|
|
|
|
|
+ .opacity(isPreview ? 0.88 : 1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var todayDateBlock: some View {
|
|
|
|
|
+ let now = Date()
|
|
|
|
|
+ let cal = Calendar.current
|
|
|
|
|
+ let day = cal.component(.day, from: now)
|
|
|
|
|
+ let weekday = Self.weekdayFormatter.string(from: now).uppercased()
|
|
|
|
|
+ let monthYear = Self.monthYearFormatter.string(from: now)
|
|
|
|
|
+
|
|
|
|
|
+ return HStack(alignment: .firstTextBaseline, spacing: 14) {
|
|
|
|
|
+ Text("\(day)")
|
|
|
|
|
+ .font(.system(size: 36, weight: .bold, design: .rounded))
|
|
|
|
|
+ .foregroundStyle(.white)
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 2) {
|
|
|
|
|
+ Text(weekday)
|
|
|
|
|
+ .font(.system(size: 10, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.55))
|
|
|
|
|
+ Text(monthYear)
|
|
|
|
|
+ .font(.system(size: 14, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.9))
|
|
|
|
|
+ }
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func nextEventSummary(_ ev: CalendarEventSnapshot) -> some View {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 4) {
|
|
|
|
|
+ Text(ev.title.isEmpty ? "(No title)" : ev.title)
|
|
|
|
|
+ .font(.system(size: 13, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.95))
|
|
|
|
|
+ .lineLimit(2)
|
|
|
|
|
+ Text(Self.eventTimeRange(ev))
|
|
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.65))
|
|
|
|
|
+ if let loc = ev.location, !loc.isEmpty {
|
|
|
|
|
+ Text(loc)
|
|
|
|
|
+ .font(.system(size: 10, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.5))
|
|
|
|
|
+ .lineLimit(1)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(10)
|
|
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
+ .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.black.opacity(0.22)))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // MARK: - Medium
|
|
|
|
|
+
|
|
|
|
|
+ private var agendaBody: some View {
|
|
|
|
|
+ ZStack(alignment: .topLeading) {
|
|
|
|
|
+ calendarGradientBackground
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 0) {
|
|
|
|
|
+ HStack(spacing: 10) {
|
|
|
|
|
+ AppIconView(app: app, size: 30, showAppBackground: false, iconPaddingFactor: 0.08)
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 2) {
|
|
|
|
|
+ Text(app.name)
|
|
|
|
|
+ .font(.system(size: 14, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.95))
|
|
|
|
|
+ Text("Agenda")
|
|
|
|
|
+ .font(.system(size: 10, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.65))
|
|
|
|
|
+ }
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ if isPreview {
|
|
|
|
|
+ Text("Sample")
|
|
|
|
|
+ .font(.system(size: 10, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.5))
|
|
|
|
|
+ .padding(.horizontal, 8)
|
|
|
|
|
+ .padding(.vertical, 4)
|
|
|
|
|
+ .background(Capsule().fill(Color.white.opacity(0.12)))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.bottom, 10)
|
|
|
|
|
+
|
|
|
|
|
+ quickAddRow
|
|
|
|
|
+ .padding(.bottom, 12)
|
|
|
|
|
+
|
|
|
|
|
+ if model.accessDenied && !isPreview {
|
|
|
|
|
+ Text("Allow Calendar access in System Settings to show your events.")
|
|
|
|
|
+ .font(.system(size: 12, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.75))
|
|
|
|
|
+ Button(action: openPrivacyCalendars) {
|
|
|
|
|
+ Text("Privacy…")
|
|
|
|
|
+ .font(.system(size: 12, weight: .bold))
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .foregroundStyle(Color(red: 0.75, green: 0.9, blue: 1))
|
|
|
|
|
+ .padding(.top, 4)
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ScrollView {
|
|
|
|
|
+ LazyVStack(alignment: .leading, spacing: 16) {
|
|
|
|
|
+ ForEach(model.agendaDays) { day in
|
|
|
|
|
+ agendaDaySection(day)
|
|
|
|
|
+ }
|
|
|
|
|
+ if model.agendaDays.isEmpty {
|
|
|
|
|
+ Text(isPreview ? "Sample events appear here." : "No events in the next several days.")
|
|
|
|
|
+ .font(.system(size: 12, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.6))
|
|
|
|
|
+ .padding(.top, 8)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.bottom, 8)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(14)
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
+ .disabled(isPreview)
|
|
|
|
|
+ .opacity(isPreview ? 0.88 : 1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var quickAddRow: some View {
|
|
|
|
|
+ HStack(spacing: 8) {
|
|
|
|
|
+ quickChip(title: "New event", systemImage: "plus.circle.fill") {
|
|
|
|
|
+ openCalendarPath("/calendar/u/0/r/eventedit")
|
|
|
|
|
+ }
|
|
|
|
|
+ quickChip(title: "Tasks", systemImage: "checkmark.square") {
|
|
|
|
|
+ openAbsoluteURL(URL(string: "https://tasks.google.com/")!)
|
|
|
|
|
+ }
|
|
|
|
|
+ quickChip(title: "Week", systemImage: "calendar.badge.clock") {
|
|
|
|
|
+ openCalendarPath("/calendar/u/0/r/week")
|
|
|
|
|
+ }
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func quickChip(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
|
|
|
|
|
+ Button(action: {
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ action()
|
|
|
|
|
+ }) {
|
|
|
|
|
+ HStack(spacing: 6) {
|
|
|
|
|
+ Image(systemName: systemImage)
|
|
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
|
|
+ Text(title)
|
|
|
|
|
+ .font(.system(size: 11, weight: .bold))
|
|
|
|
|
+ .lineLimit(1)
|
|
|
|
|
+ }
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.92))
|
|
|
|
|
+ .padding(.horizontal, 10)
|
|
|
|
|
+ .padding(.vertical, 8)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
|
|
+ .fill(Color.black.opacity(0.28))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
|
|
+ .stroke(Color.white.opacity(0.14), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .disabled(isPreview)
|
|
|
|
|
+ .allowsHitTesting(!isPreview)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func agendaDaySection(_ day: CalendarAgendaDay) -> some View {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
|
|
+ Text(day.dayLabel)
|
|
|
|
|
+ .font(.system(size: 11, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.45))
|
|
|
|
|
+
|
|
|
|
|
+ ForEach(day.events) { ev in
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 4) {
|
|
|
|
|
+ HStack(alignment: .firstTextBaseline) {
|
|
|
|
|
+ Text(Self.eventTimeRange(ev))
|
|
|
|
|
+ .font(.system(size: 11, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(Color(red: 0.75, green: 0.92, blue: 0.85))
|
|
|
|
|
+ .frame(width: 108, alignment: .leading)
|
|
|
|
|
+ Text(ev.title.isEmpty ? "(No title)" : ev.title)
|
|
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.95))
|
|
|
|
|
+ .lineLimit(2)
|
|
|
|
|
+ }
|
|
|
|
|
+ if let loc = ev.location, !loc.isEmpty {
|
|
|
|
|
+ HStack(spacing: 4) {
|
|
|
|
|
+ Image(systemName: "mappin.and.ellipse")
|
|
|
|
|
+ .font(.system(size: 9, weight: .semibold))
|
|
|
|
|
+ Text(loc)
|
|
|
|
|
+ .font(.system(size: 10, weight: .medium))
|
|
|
|
|
+ .lineLimit(2)
|
|
|
|
|
+ }
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.45))
|
|
|
|
|
+ .padding(.leading, 108)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(10)
|
|
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
+ .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.black.opacity(0.2)))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // MARK: - Chrome
|
|
|
|
|
+
|
|
|
|
|
+ private var calendarGradientBackground: some View {
|
|
|
|
|
+ LinearGradient(
|
|
|
|
|
+ colors: [
|
|
|
|
|
+ Color(red: 0.16, green: 0.38, blue: 0.55),
|
|
|
|
|
+ Color(red: 0.1, green: 0.14, blue: 0.28),
|
|
|
|
|
+ ],
|
|
|
|
|
+ startPoint: .topLeading,
|
|
|
|
|
+ endPoint: .bottomTrailing
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func calendarPrimaryButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
|
|
|
|
|
+ Button(action: {
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ action()
|
|
|
|
|
+ }) {
|
|
|
|
|
+ HStack(spacing: 8) {
|
|
|
|
|
+ Image(systemName: systemImage)
|
|
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
|
|
+ Text(title)
|
|
|
|
|
+ .font(.system(size: 13, weight: .bold))
|
|
|
|
|
+ }
|
|
|
|
|
+ .foregroundStyle(Color(red: 0.06, green: 0.12, blue: 0.18))
|
|
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
|
|
+ .padding(.vertical, 11)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
+ .fill(Color.white.opacity(0.9))
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .disabled(isPreview)
|
|
|
|
|
+ .allowsHitTesting(!isPreview)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func openCalendarPath(_ path: String) {
|
|
|
|
|
+ guard !isPreview else { return }
|
|
|
|
|
+ let url = WidgetDeepLinkURL.resolved(base: baseURL, actionPath: path, accountSlot: 0)
|
|
|
|
|
+ InAppBrowserWindowManager.shared.open(url: url, title: app.name)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func openAbsoluteURL(_ url: URL) {
|
|
|
|
|
+ guard !isPreview else { return }
|
|
|
|
|
+ InAppBrowserWindowManager.shared.open(url: url, title: app.name)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func openPrivacyCalendars() {
|
|
|
|
|
+ guard !isPreview else { return }
|
|
|
|
|
+ let s = "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars"
|
|
|
|
|
+ if let url = URL(string: s) {
|
|
|
|
|
+ NSWorkspace.shared.open(url)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static let weekdayFormatter: DateFormatter = {
|
|
|
|
|
+ let f = DateFormatter()
|
|
|
|
|
+ f.dateFormat = "EEEE"
|
|
|
|
|
+ return f
|
|
|
|
|
+ }()
|
|
|
|
|
+
|
|
|
|
|
+ private static let monthYearFormatter: DateFormatter = {
|
|
|
|
|
+ let f = DateFormatter()
|
|
|
|
|
+ f.dateFormat = "MMMM yyyy"
|
|
|
|
|
+ return f
|
|
|
|
|
+ }()
|
|
|
|
|
+
|
|
|
|
|
+ private static func eventTimeRange(_ ev: CalendarEventSnapshot) -> String {
|
|
|
|
|
+ if ev.isAllDay {
|
|
|
|
|
+ return "All day"
|
|
|
|
|
+ }
|
|
|
|
|
+ let t = DateFormatter()
|
|
|
|
|
+ t.timeStyle = .short
|
|
|
|
|
+ t.dateStyle = .none
|
|
|
|
|
+ let start = t.string(from: ev.start)
|
|
|
|
|
+ let end = t.string(from: ev.end)
|
|
|
|
|
+ return "\(start) – \(end)"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - EventKit
|
|
|
|
|
+
|
|
|
|
|
+@MainActor
|
|
|
|
|
+private final class CalendarWidgetEventsModel: ObservableObject {
|
|
|
|
|
+ @Published private(set) var nextEvent: CalendarEventSnapshot?
|
|
|
|
|
+ @Published private(set) var agendaDays: [CalendarAgendaDay] = []
|
|
|
|
|
+ @Published private(set) var accessDenied = false
|
|
|
|
|
+
|
|
|
|
|
+ private let store = EKEventStore()
|
|
|
|
|
+
|
|
|
|
|
+ func refresh(isPreview: Bool) async {
|
|
|
|
|
+ if isPreview {
|
|
|
|
|
+ loadSample()
|
|
|
|
|
+ accessDenied = false
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let status = EKEventStore.authorizationStatus(for: .event)
|
|
|
|
|
+ if Self.canReadEvents(status) {
|
|
|
|
|
+ accessDenied = false
|
|
|
|
|
+ fetchAndPublish()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if status == .denied || status == .restricted {
|
|
|
|
|
+ accessDenied = true
|
|
|
|
|
+ nextEvent = nil
|
|
|
|
|
+ agendaDays = []
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let granted = await requestAccess()
|
|
|
|
|
+ accessDenied = !granted
|
|
|
|
|
+ if granted {
|
|
|
|
|
+ fetchAndPublish()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ nextEvent = nil
|
|
|
|
|
+ agendaDays = []
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func requestAccess() async -> Bool {
|
|
|
|
|
+ await withCheckedContinuation { continuation in
|
|
|
|
|
+ if #available(macOS 14.0, *) {
|
|
|
|
|
+ store.requestFullAccessToEvents { granted, _ in
|
|
|
|
|
+ continuation.resume(returning: granted)
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ store.requestAccess(to: .event) { granted, _ in
|
|
|
|
|
+ continuation.resume(returning: granted)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func fetchAndPublish() {
|
|
|
|
|
+ let now = Date()
|
|
|
|
|
+ let cal = Calendar.current
|
|
|
|
|
+ let startOfToday = cal.startOfDay(for: now)
|
|
|
|
|
+ guard let rangeEnd = cal.date(byAdding: .day, value: 7, to: startOfToday) else {
|
|
|
|
|
+ nextEvent = nil
|
|
|
|
|
+ agendaDays = []
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let predicate = store.predicateForEvents(withStart: startOfToday, end: rangeEnd, calendars: nil)
|
|
|
|
|
+ let events = store.events(matching: predicate).sorted { $0.startDate < $1.startDate }
|
|
|
|
|
+
|
|
|
|
|
+ let snapshots: [CalendarEventSnapshot] = events.map { e in
|
|
|
|
|
+ CalendarEventSnapshot(
|
|
|
|
|
+ id: e.eventIdentifier,
|
|
|
|
|
+ title: e.title ?? "",
|
|
|
|
|
+ start: e.startDate,
|
|
|
|
|
+ end: e.endDate,
|
|
|
|
|
+ isAllDay: e.isAllDay,
|
|
|
|
|
+ location: e.location
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let upcoming = snapshots.filter { $0.end > now }
|
|
|
|
|
+ nextEvent = upcoming.first
|
|
|
|
|
+
|
|
|
|
|
+ var byDay: [Date: [CalendarEventSnapshot]] = [:]
|
|
|
|
|
+ for ev in upcoming {
|
|
|
|
|
+ let dayStart = cal.startOfDay(for: ev.start)
|
|
|
|
|
+ byDay[dayStart, default: []].append(ev)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let sortedDays = byDay.keys.sorted()
|
|
|
|
|
+ agendaDays = sortedDays.map { day in
|
|
|
|
|
+ let label = Self.dayHeading(for: day, calendar: cal)
|
|
|
|
|
+ let id = "\(day.timeIntervalSince1970)"
|
|
|
|
|
+ let dayEvents = (byDay[day] ?? []).sorted { $0.start < $1.start }
|
|
|
|
|
+ return CalendarAgendaDay(id: id, dayLabel: label, events: dayEvents)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func loadSample() {
|
|
|
|
|
+ let cal = Calendar.current
|
|
|
|
|
+ let now = Date()
|
|
|
|
|
+ let later = cal.date(byAdding: .hour, value: 2, to: now) ?? now
|
|
|
|
|
+ let end = cal.date(byAdding: .hour, value: 3, to: now) ?? now
|
|
|
|
|
+
|
|
|
|
|
+ let s1 = CalendarEventSnapshot(id: "s1", title: "Product review", start: later, end: end, isAllDay: false, location: "Building A")
|
|
|
|
|
+ nextEvent = s1
|
|
|
|
|
+
|
|
|
|
|
+ let tomorrow = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: now)) ?? now
|
|
|
|
|
+ let tStart = cal.date(byAdding: .hour, value: 10, to: tomorrow) ?? tomorrow
|
|
|
|
|
+ let tEnd = cal.date(byAdding: .hour, value: 11, to: tomorrow) ?? tomorrow
|
|
|
|
|
+ let s2 = CalendarEventSnapshot(id: "s2", title: "Design sync", start: tStart, end: tEnd, isAllDay: false, location: nil)
|
|
|
|
|
+
|
|
|
|
|
+ agendaDays = [
|
|
|
|
|
+ CalendarAgendaDay(
|
|
|
|
|
+ id: "d0",
|
|
|
|
|
+ dayLabel: "Today",
|
|
|
|
|
+ events: [s1]
|
|
|
|
|
+ ),
|
|
|
|
|
+ CalendarAgendaDay(
|
|
|
|
|
+ id: "d1",
|
|
|
|
|
+ dayLabel: "Tomorrow",
|
|
|
|
|
+ events: [s2]
|
|
|
|
|
+ ),
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func dayHeading(for dayStart: Date, calendar: Calendar) -> String {
|
|
|
|
|
+ if calendar.isDateInToday(dayStart) { return "Today" }
|
|
|
|
|
+ if calendar.isDateInTomorrow(dayStart) { return "Tomorrow" }
|
|
|
|
|
+ let f = DateFormatter()
|
|
|
|
|
+ f.dateFormat = "EEEE, MMM d"
|
|
|
|
|
+ return f.string(from: dayStart)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func canReadEvents(_ status: EKAuthorizationStatus) -> Bool {
|
|
|
|
|
+ switch status {
|
|
|
|
|
+ case .authorized:
|
|
|
|
|
+ return true
|
|
|
|
|
+ default:
|
|
|
|
|
+ if #available(macOS 14.0, *) {
|
|
|
|
|
+ return status == .fullAccess
|
|
|
|
|
+ }
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|