Преглед изворни кода

Redesign Google Calendar desktop widget (small/medium, drop large)

Small shows today’s date and the next EventKit event; medium shows a
multi-day agenda with quick links to new event, Tasks, and week view.
Adds CalendarDesktopWidgetView, calendar layout mode, sandbox calendar
entitlement, and NSCalendarsUsageDescription for EventKit access.

Made-with: Cursor
huzaifahayat12 пре 3 месеци
родитељ
комит
a1f2c3f01d

+ 2 - 0
google_apps.xcodeproj/project.pbxproj

@@ -257,6 +257,7 @@
 				GENERATE_INFOPLIST_FILE = YES;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Google Maps uses your location to center the map and suggest nearby places.";
 				INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Google Maps uses your location to center the map and suggest nearby places.";
+				INFOPLIST_KEY_NSCalendarsUsageDescription = "The Calendar widget shows upcoming events from Apple Calendar (including calendars synced with Google) and opens Google Calendar for scheduling.";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				LD_RUNPATH_SEARCH_PATHS = (
 				LD_RUNPATH_SEARCH_PATHS = (
@@ -290,6 +291,7 @@
 				GENERATE_INFOPLIST_FILE = YES;
 				GENERATE_INFOPLIST_FILE = YES;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Google Maps uses your location to center the map and suggest nearby places.";
 				INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Google Maps uses your location to center the map and suggest nearby places.";
+				INFOPLIST_KEY_NSCalendarsUsageDescription = "The Calendar widget shows upcoming events from Apple Calendar (including calendars synced with Google) and opens Google Calendar for scheduling.";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				LD_RUNPATH_SEARCH_PATHS = (
 				LD_RUNPATH_SEARCH_PATHS = (

+ 524 - 0
google_apps/Widgets/CalendarDesktopWidgetView.swift

@@ -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
+        }
+    }
+}

+ 3 - 1
google_apps/Widgets/DesktopWidgetView.swift

@@ -27,6 +27,8 @@ struct DesktopWidgetView: View {
                 EarthDesktopWidgetView(app: app, mode: earthMode, isPreview: isPreview)
                 EarthDesktopWidgetView(app: app, mode: earthMode, isPreview: isPreview)
             } else if case .googleSearch(let searchMode) = layoutMode {
             } else if case .googleSearch(let searchMode) = layoutMode {
                 GoogleSearchDesktopWidgetView(app: app, mode: searchMode, isPreview: isPreview)
                 GoogleSearchDesktopWidgetView(app: app, mode: searchMode, isPreview: isPreview)
+            } else if case .calendar(let calendarMode) = layoutMode {
+                CalendarDesktopWidgetView(app: app, mode: calendarMode, isPreview: isPreview)
             } else if case .interactiveMap = layoutMode {
             } else if case .interactiveMap = layoutMode {
                 MapsInteractiveWidgetView(app: app, widgetSize: size, isPreview: isPreview)
                 MapsInteractiveWidgetView(app: app, widgetSize: size, isPreview: isPreview)
             } else {
             } else {
@@ -62,7 +64,7 @@ struct DesktopWidgetView: View {
                             }
                             }
                             gridActions(columns: columns, maxActions: maxActionsForSize)
                             gridActions(columns: columns, maxActions: maxActionsForSize)
 
 
-                        case .interactiveMap, .gmail, .drive, .keep, .translate, .earth, .googleSearch:
+                        case .interactiveMap, .gmail, .drive, .keep, .translate, .earth, .googleSearch, .calendar:
                             EmptyView()
                             EmptyView()
                         }
                         }
                     }
                     }

+ 22 - 20
google_apps/Widgets/WidgetTemplates.swift

@@ -56,6 +56,13 @@ enum GoogleSearchWidgetMode: Hashable, Sendable {
     case interactive
     case interactive
 }
 }
 
 
+enum CalendarWidgetMode: Hashable, Sendable {
+    /// Today’s date and the next upcoming event (from Apple Calendar / synced accounts).
+    case compact
+    /// Multi-day agenda with quick links to create events in Google Calendar.
+    case agenda
+}
+
 enum WidgetLayoutMode: Hashable {
 enum WidgetLayoutMode: Hashable {
     case iconOnly(title: String)
     case iconOnly(title: String)
     case actionsRow
     case actionsRow
@@ -74,6 +81,8 @@ enum WidgetLayoutMode: Hashable {
     case earth(EarthWidgetMode)
     case earth(EarthWidgetMode)
     /// Embedded Google Search (`WKWebView`): full search experience in-panel.
     /// Embedded Google Search (`WKWebView`): full search experience in-panel.
     case googleSearch(GoogleSearchWidgetMode)
     case googleSearch(GoogleSearchWidgetMode)
+    /// Today + next event (small) or multi-day agenda + quick add (medium); uses EventKit + Google Calendar links.
+    case calendar(CalendarWidgetMode)
 }
 }
 
 
 struct WidgetVariant: Identifiable, Hashable {
 struct WidgetVariant: Identifiable, Hashable {
@@ -202,26 +211,8 @@ enum WidgetTemplates {
             ]
             ]
         case .calendar:
         case .calendar:
             return [
             return [
-                v("calendar_small", "Today", .small, false, .actionsRow, [
-                    a("today", "Today", "calendar", "/calendar/u/0/r"),
-                    a("create", "Create", "plus.circle", "/calendar/u/0/r/eventedit"),
-                ]),
-                v("calendar_medium", "Schedule", .medium, true, .actionsGrid(columns: 2), [
-                    a("today", "Today", "calendar", "/calendar/u/0/r"),
-                    a("week", "Week", "calendar.badge.clock", "/calendar/u/0/r/week"),
-                    a("create", "New Event", "plus.circle", "/calendar/u/0/r/eventedit"),
-                    a("tasks", "Tasks", "checkmark.square", "https://tasks.google.com/"),
-                ]),
-                v("calendar_large", "Calendar Hub", .large, true, .actionsGrid(columns: 2), [
-                    a("today", "Today", "calendar", "/calendar/u/0/r"),
-                    a("week", "Week", "calendar.badge.clock", "/calendar/u/0/r/week"),
-                    a("month", "Month", "calendar", "/calendar/u/0/r/month"),
-                    a("create", "New Event", "plus.circle", "/calendar/u/0/r/eventedit"),
-                    a("tasks", "Tasks", "checkmark.square", "https://tasks.google.com/"),
-                    a("settings", "Settings", "gearshape", "/calendar/u/0/r/settings"),
-                    a("search", "Search", "magnifyingglass", "/calendar/u/0/r/search"),
-                    a("print", "Print", "printer", "/calendar/u/0/r/print"),
-                ]),
+                v("calendar_small", "Today & next", .small, false, .calendar(.compact), []),
+                v("calendar_medium", "Agenda", .medium, false, .calendar(.agenda), []),
             ]
             ]
         case .maps:
         case .maps:
             // Live map: `MapsInteractiveWidgetView` (embedded `WKWebView`); actions unused.
             // Live map: `MapsInteractiveWidgetView` (embedded `WKWebView`); actions unused.
@@ -594,6 +585,7 @@ extension WidgetLayoutMode {
         case .translate(.interactive): true
         case .translate(.interactive): true
         case .earth(.interactive): true
         case .earth(.interactive): true
         case .googleSearch(.interactive): true
         case .googleSearch(.interactive): true
+        case .calendar: false
         default: false
         default: false
         }
         }
     }
     }
@@ -624,6 +616,11 @@ extension WidgetLayoutMode {
             return CGSize(width: 580, height: 640)
             return CGSize(width: 580, height: 640)
         case .googleSearch(.interactive):
         case .googleSearch(.interactive):
             return CGSize(width: 480, height: 220)
             return CGSize(width: 480, height: 220)
+        case .calendar(let mode):
+            switch mode {
+            case .compact: return CGSize(width: 230, height: 260)
+            case .agenda: return CGSize(width: 420, height: 480)
+            }
         default:
         default:
             return nil
             return nil
         }
         }
@@ -654,6 +651,11 @@ extension WidgetLayoutMode {
             return CGSize(width: 490, height: 470)
             return CGSize(width: 490, height: 470)
         case .googleSearch(.interactive):
         case .googleSearch(.interactive):
             return CGSize(width: 440, height: 210)
             return CGSize(width: 440, height: 210)
+        case .calendar(let mode):
+            switch mode {
+            case .compact: return CGSize(width: 210, height: 240)
+            case .agenda: return CGSize(width: 380, height: 440)
+            }
         default:
         default:
             return nil
             return nil
         }
         }

+ 2 - 0
google_apps/google_apps.entitlements

@@ -12,5 +12,7 @@
 	<true/>
 	<true/>
 	<key>com.apple.security.personal-information.location</key>
 	<key>com.apple.security.personal-information.location</key>
 	<true/>
 	<true/>
+	<key>com.apple.security.personal-information.calendars</key>
+	<true/>
 </dict>
 </dict>
 </plist>
 </plist>