Kaynağa Gözat

Add Meet desktop widgets: Calendar-backed joins for small/medium/large

Introduce MeetWidgetMode (compact, agenda, hub) and MeetDesktopWidgetView.
Read Apple Calendar via EventKit, detect meet.google.com links in event
URL/location/notes, and expose quick join, upcoming list, and a hub with
details, recent links, and shortcuts. Join/create open in the in-app browser.

Replace generic Meet action-grid variants with meet_small/medium/large wired
to the new layout; set panel and preview card sizes.

Wire DesktopWidgetView for .meet(...).

Made-with: Cursor
huzaifahayat12 3 ay önce
ebeveyn
işleme
c1f5ce2792

+ 3 - 1
google_apps/Widgets/DesktopWidgetView.swift

@@ -35,6 +35,8 @@ struct DesktopWidgetView: View {
                 TravelDesktopWidgetView(app: app, mode: travelMode, isPreview: isPreview)
             } else if case .youtube(let youtubeMode) = layoutMode {
                 YouTubeDesktopWidgetView(app: app, mode: youtubeMode, isPreview: isPreview)
+            } else if case .meet(let meetMode) = layoutMode {
+                MeetDesktopWidgetView(app: app, mode: meetMode, isPreview: isPreview)
             } else if case .interactiveMap = layoutMode {
                 MapsInteractiveWidgetView(app: app, widgetSize: size, isPreview: isPreview)
             } else {
@@ -70,7 +72,7 @@ struct DesktopWidgetView: View {
                             }
                             gridActions(columns: columns, maxActions: maxActionsForSize)
 
-                        case .interactiveMap, .gmail, .drive, .keep, .translate, .earth, .googleSearch, .calendar, .books, .travel, .youtube:
+                        case .interactiveMap, .gmail, .drive, .keep, .translate, .earth, .googleSearch, .calendar, .books, .travel, .youtube, .meet:
                             EmptyView()
                         }
                     }

+ 639 - 0
google_apps/Widgets/MeetDesktopWidgetView.swift

@@ -0,0 +1,639 @@
+import AppKit
+import Combine
+import EventKit
+import SwiftUI
+
+struct MeetMeetingSnapshot: Identifiable, Hashable {
+    let id: String
+    let title: String
+    let start: Date
+    let end: Date
+    let meetURL: URL
+    let location: String?
+}
+
+/// Google Meet widgets: calendar-backed Meet links from Apple Calendar (events with `meet.google.com` in URL, location, or notes).
+struct MeetDesktopWidgetView: View {
+    let app: LauncherApp
+    let mode: MeetWidgetMode
+    var isPreview: Bool = false
+
+    @StateObject private var model = MeetWidgetEventsModel()
+
+    private var meetBase: URL { app.webURL ?? URL(string: "https://meet.google.com")! }
+
+    var body: some View {
+        Group {
+            switch mode {
+            case .compact:
+                compactBody
+            case .agenda:
+                agendaBody
+            case .hub:
+                hubBody
+            }
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+        .task(id: isPreview) {
+            await model.refresh(isPreview: isPreview)
+        }
+    }
+
+    // MARK: - Small — quick join
+
+    private var compactBody: some View {
+        ZStack(alignment: .topLeading) {
+            meetGradientBackground
+            VStack(alignment: .leading, spacing: 10) {
+                HStack(spacing: 8) {
+                    AppIconView(app: app, size: 30, showAppBackground: false, iconPaddingFactor: 0.08)
+                    Text("Meet")
+                        .font(.system(size: 14, weight: .bold))
+                        .foregroundStyle(.white.opacity(0.95))
+                    Spacer(minLength: 0)
+                }
+
+                if model.accessDenied && !isPreview {
+                    Text("Calendar access is off. Enable Calendar in System Settings to see meetings with Meet links.")
+                        .font(.system(size: 11, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.75))
+                        .fixedSize(horizontal: false, vertical: true)
+                    Button("Open Privacy settings", action: openPrivacyCalendars)
+                        .font(.system(size: 11, weight: .bold))
+                        .buttonStyle(.plain)
+                        .foregroundStyle(Color(red: 0.75, green: 0.95, blue: 0.85))
+                } else if let next = model.nextMeet {
+                    Text("Next")
+                        .font(.system(size: 10, weight: .bold))
+                        .foregroundStyle(.white.opacity(0.5))
+                    Text(next.title)
+                        .font(.system(size: 13, weight: .semibold))
+                        .foregroundStyle(.white)
+                        .lineLimit(2)
+                    Text(Self.formatStart(next.start, relativeTo: next.end))
+                        .font(.system(size: 11, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.75))
+                    meetPrimaryButton(title: "Join", systemImage: "video.fill") {
+                        openMeetURL(next.meetURL)
+                    }
+                } else if let last = model.lastMeet {
+                    Text("Last meeting")
+                        .font(.system(size: 10, weight: .bold))
+                        .foregroundStyle(.white.opacity(0.5))
+                    Text(last.title)
+                        .font(.system(size: 13, weight: .semibold))
+                        .foregroundStyle(.white.opacity(0.9))
+                        .lineLimit(2)
+                    Text("Ended \(Self.timeFormatter.string(from: last.end))")
+                        .font(.system(size: 11, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.65))
+                    meetPrimaryButton(title: "Open link", systemImage: "link") {
+                        openMeetURL(last.meetURL)
+                    }
+                } else {
+                    Text(isPreview ? "Sample: sync at 3:00 PM" : "No Meet links in Calendar")
+                        .font(.system(size: 12, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.75))
+                    meetPrimaryButton(title: "New meeting", systemImage: "video.badge.plus") {
+                        openMeetPath("/new")
+                    }
+                }
+            }
+            .padding(14)
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        }
+        .disabled(isPreview)
+        .opacity(isPreview ? 0.88 : 1)
+    }
+
+    // MARK: - Medium — upcoming list
+
+    private var agendaBody: some View {
+        ZStack(alignment: .topLeading) {
+            meetGradientBackground
+            VStack(alignment: .leading, spacing: 10) {
+                HStack(spacing: 8) {
+                    AppIconView(app: app, size: 28, showAppBackground: false, iconPaddingFactor: 0.08)
+                    Text("Upcoming")
+                        .font(.system(size: 14, weight: .bold))
+                        .foregroundStyle(.white.opacity(0.95))
+                    Spacer(minLength: 0)
+                    Button(action: { openMeetPath("/new") }) {
+                        Image(systemName: "plus.circle.fill")
+                            .font(.system(size: 20, weight: .semibold))
+                            .foregroundStyle(Self.accentGreen)
+                    }
+                    .buttonStyle(.plain)
+                    .help("New meeting")
+                }
+
+                if model.accessDenied && !isPreview {
+                    Text("Turn on Calendar access in System Settings to list Meet meetings.")
+                        .font(.system(size: 11, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.75))
+                    Button("Open Privacy settings", action: openPrivacyCalendars)
+                        .font(.system(size: 11, weight: .bold))
+                        .buttonStyle(.plain)
+                        .foregroundStyle(Color(red: 0.75, green: 0.95, blue: 0.85))
+                } else if model.upcomingMeets.isEmpty {
+                    Text(isPreview ? "Design review · 2:00 PM" : "No upcoming meetings with Meet links")
+                        .font(.system(size: 12, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.8))
+                    meetPrimaryButton(title: "New meeting", systemImage: "video.badge.plus") {
+                        openMeetPath("/new")
+                    }
+                } else {
+                    ScrollView {
+                        VStack(alignment: .leading, spacing: 8) {
+                            ForEach(model.upcomingMeets) { ev in
+                                agendaRow(ev)
+                            }
+                        }
+                    }
+                    .frame(maxHeight: .infinity)
+                }
+            }
+            .padding(14)
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        }
+        .disabled(isPreview)
+        .opacity(isPreview ? 0.88 : 1)
+    }
+
+    private func agendaRow(_ ev: MeetMeetingSnapshot) -> some View {
+        HStack(alignment: .center, spacing: 10) {
+            VStack(alignment: .leading, spacing: 3) {
+                Text(ev.title)
+                    .font(.system(size: 12, weight: .semibold))
+                    .foregroundStyle(.white)
+                    .lineLimit(2)
+                Text(Self.formatStart(ev.start, relativeTo: ev.end))
+                    .font(.system(size: 10, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.65))
+            }
+            Spacer(minLength: 0)
+            Button(action: { openMeetURL(ev.meetURL) }) {
+                Text("Join")
+                    .font(.system(size: 11, weight: .bold))
+                    .foregroundStyle(.white)
+                    .padding(.horizontal, 12)
+                    .padding(.vertical, 6)
+                    .background(Capsule().fill(Self.accentGreen.opacity(0.95)))
+            }
+            .buttonStyle(.plain)
+        }
+        .padding(10)
+        .background(
+            RoundedRectangle(cornerRadius: 12, style: .continuous)
+                .fill(Color.white.opacity(0.08))
+        )
+    }
+
+    // MARK: - Large — hub
+
+    private var hubBody: some View {
+        ZStack(alignment: .topLeading) {
+            meetGradientBackground
+            ScrollView {
+                VStack(alignment: .leading, spacing: 14) {
+                    HStack(spacing: 8) {
+                        AppIconView(app: app, size: 30, showAppBackground: false, iconPaddingFactor: 0.08)
+                        VStack(alignment: .leading, spacing: 2) {
+                            Text("Google Meet")
+                                .font(.system(size: 15, weight: .bold))
+                                .foregroundStyle(.white)
+                            Text("From your Calendar")
+                                .font(.system(size: 10, weight: .semibold))
+                                .foregroundStyle(.white.opacity(0.55))
+                        }
+                        Spacer(minLength: 0)
+                        Button(action: { openMeetPath("/") }) {
+                            Image(systemName: "safari")
+                                .font(.system(size: 15, weight: .semibold))
+                                .foregroundStyle(.white.opacity(0.9))
+                                .padding(8)
+                                .background(Circle().fill(Color.white.opacity(0.12)))
+                        }
+                        .buttonStyle(.plain)
+                        .help("Open Meet in browser")
+                    }
+
+                    if model.accessDenied && !isPreview {
+                        Text("Calendar access is required to show meetings. Enable it under Privacy & Security → Calendars.")
+                            .font(.system(size: 11, weight: .medium))
+                            .foregroundStyle(.white.opacity(0.75))
+                        Button("Open Privacy settings", action: openPrivacyCalendars)
+                            .font(.system(size: 11, weight: .bold))
+                            .buttonStyle(.plain)
+                            .foregroundStyle(Color(red: 0.75, green: 0.95, blue: 0.85))
+                    } else {
+                        HStack(spacing: 10) {
+                            if let next = model.nextMeet {
+                                meetPrimaryButton(title: "Join next", systemImage: "video.fill") {
+                                    openMeetURL(next.meetURL)
+                                }
+                            } else {
+                                meetPrimaryButton(title: "New meeting", systemImage: "video.badge.plus") {
+                                    openMeetPath("/new")
+                                }
+                            }
+                            Button(action: { openMeetPath("/new") }) {
+                                VStack(spacing: 4) {
+                                    Image(systemName: "plus.circle.fill")
+                                        .font(.system(size: 22, weight: .semibold))
+                                    Text("Create")
+                                        .font(.system(size: 10, weight: .bold))
+                                }
+                                .foregroundStyle(.white)
+                                .frame(maxWidth: .infinity)
+                                .padding(.vertical, 12)
+                                .background(
+                                    RoundedRectangle(cornerRadius: 14, style: .continuous)
+                                        .fill(Color.white.opacity(0.12))
+                                )
+                            }
+                            .buttonStyle(.plain)
+                        }
+
+                        if let next = model.nextMeet {
+                            hubSectionTitle("Next meeting details")
+                            meetDetailCard(next)
+                        }
+
+                        hubSectionTitle("Scheduled")
+                        if model.upcomingMeets.isEmpty {
+                            Text(isPreview ? "Weekly sync · Today 3:00 PM" : "No upcoming Meet events")
+                                .font(.system(size: 11, weight: .medium))
+                                .foregroundStyle(.white.opacity(0.65))
+                        } else {
+                            ForEach(model.upcomingMeets) { ev in
+                                hubScheduledRow(ev)
+                            }
+                        }
+
+                        hubSectionTitle("Recent")
+                        if model.recentMeets.isEmpty {
+                            Text("No recent Meet links")
+                                .font(.system(size: 11, weight: .medium))
+                                .foregroundStyle(.white.opacity(0.55))
+                        } else {
+                            ForEach(model.recentMeets) { ev in
+                                hubRecentRow(ev)
+                            }
+                        }
+
+                        hubSectionTitle("Shortcuts")
+                        HStack(spacing: 8) {
+                            hubShortcut(title: "Calendar", systemImage: "calendar", action: {
+                                if let u = URL(string: "https://calendar.google.com/calendar/u/0/r") {
+                                    InAppBrowserWindowManager.shared.open(url: u, title: "Google Calendar")
+                                }
+                            })
+                            hubShortcut(title: "Join code", systemImage: "keyboard", action: {
+                                openMeetPath("/")
+                            })
+                        }
+                    }
+                }
+                .padding(14)
+            }
+        }
+        .disabled(isPreview)
+        .opacity(isPreview ? 0.88 : 1)
+    }
+
+    private func hubSectionTitle(_ text: String) -> some View {
+        Text(text)
+            .font(.system(size: 10, weight: .bold))
+            .foregroundStyle(.white.opacity(0.45))
+            .textCase(.uppercase)
+    }
+
+    private func meetDetailCard(_ ev: MeetMeetingSnapshot) -> some View {
+        VStack(alignment: .leading, spacing: 6) {
+            Text(ev.title)
+                .font(.system(size: 14, weight: .bold))
+                .foregroundStyle(.white)
+            Text(Self.formatRange(ev.start, ev.end))
+                .font(.system(size: 12, weight: .medium))
+                .foregroundStyle(.white.opacity(0.75))
+            if let loc = ev.location, !loc.isEmpty {
+                Text(loc)
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.55))
+                    .lineLimit(2)
+            }
+            Button(action: { openMeetURL(ev.meetURL) }) {
+                Label("Join with Google Meet", systemImage: "video.fill")
+                    .font(.system(size: 12, weight: .bold))
+                    .frame(maxWidth: .infinity)
+                    .padding(.vertical, 10)
+                    .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Self.accentGreen))
+            }
+            .buttonStyle(.plain)
+            .foregroundStyle(.white)
+        }
+        .padding(12)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(
+            RoundedRectangle(cornerRadius: 16, style: .continuous)
+                .fill(Color.white.opacity(0.08))
+        )
+    }
+
+    private func hubScheduledRow(_ ev: MeetMeetingSnapshot) -> some View {
+        HStack {
+            VStack(alignment: .leading, spacing: 3) {
+                Text(ev.title)
+                    .font(.system(size: 12, weight: .semibold))
+                    .foregroundStyle(.white)
+                Text(Self.formatStart(ev.start, relativeTo: ev.end))
+                    .font(.system(size: 10, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.6))
+            }
+            Spacer(minLength: 0)
+            Button("Join") { openMeetURL(ev.meetURL) }
+                .font(.system(size: 11, weight: .bold))
+                .buttonStyle(.borderedProminent)
+                .tint(Self.accentGreen)
+                .controlSize(.small)
+        }
+        .padding(10)
+        .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.white.opacity(0.06)))
+    }
+
+    private func hubRecentRow(_ ev: MeetMeetingSnapshot) -> some View {
+        HStack {
+            VStack(alignment: .leading, spacing: 3) {
+                Text(ev.title)
+                    .font(.system(size: 11, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.85))
+                Text("Ended \(Self.timeFormatter.string(from: ev.end))")
+                    .font(.system(size: 10, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.5))
+            }
+            Spacer(minLength: 0)
+            Button(action: { openMeetURL(ev.meetURL) }) {
+                Image(systemName: "arrow.up.right.square")
+                    .font(.system(size: 14, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.8))
+            }
+            .buttonStyle(.plain)
+            .help("Open Meet link")
+        }
+        .padding(10)
+        .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.white.opacity(0.05)))
+    }
+
+    private func hubShortcut(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
+        Button(action: action) {
+            HStack(spacing: 6) {
+                Image(systemName: systemImage)
+                    .font(.system(size: 13, weight: .semibold))
+                Text(title)
+                    .font(.system(size: 11, weight: .bold))
+            }
+            .foregroundStyle(.white.opacity(0.9))
+            .frame(maxWidth: .infinity)
+            .padding(.vertical, 10)
+            .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.white.opacity(0.1)))
+        }
+        .buttonStyle(.plain)
+    }
+
+    private var meetGradientBackground: some View {
+        LinearGradient(
+            colors: [
+                Color(red: 0.05, green: 0.14, blue: 0.12),
+                Color(red: 0.07, green: 0.09, blue: 0.1),
+            ],
+            startPoint: .topLeading,
+            endPoint: .bottomTrailing
+        )
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+    }
+
+    private func meetPrimaryButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
+        Button(action: action) {
+            HStack(spacing: 8) {
+                Image(systemName: systemImage)
+                    .font(.system(size: 14, weight: .semibold))
+                Text(title)
+                    .font(.system(size: 13, weight: .bold))
+            }
+            .foregroundStyle(.white)
+            .frame(maxWidth: .infinity)
+            .padding(.vertical, 11)
+            .background(
+                RoundedRectangle(cornerRadius: 14, style: .continuous)
+                    .fill(Self.accentGreen)
+            )
+        }
+        .buttonStyle(.plain)
+    }
+
+    private static let accentGreen = Color(red: 0.16, green: 0.65, blue: 0.48)
+
+    private static let timeFormatter: DateFormatter = {
+        let f = DateFormatter()
+        f.timeStyle = .short
+        f.dateStyle = .none
+        return f
+    }()
+
+    private static let dayFormatter: DateFormatter = {
+        let f = DateFormatter()
+        f.dateFormat = "EEE, MMM d"
+        return f
+    }()
+
+    private static func formatStart(_ start: Date, relativeTo end: Date) -> String {
+        let cal = Calendar.current
+        if cal.isDateInToday(start) {
+            return "Today · \(timeFormatter.string(from: start)) – \(timeFormatter.string(from: end))"
+        }
+        if cal.isDateInTomorrow(start) {
+            return "Tomorrow · \(timeFormatter.string(from: start))"
+        }
+        return "\(dayFormatter.string(from: start)) · \(timeFormatter.string(from: start))"
+    }
+
+    private static func formatRange(_ start: Date, _ end: Date) -> String {
+        "\(timeFormatter.string(from: start)) – \(timeFormatter.string(from: end))"
+    }
+
+    private func openMeetURL(_ url: URL) {
+        guard !isPreview else { return }
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    private func openMeetPath(_ path: String) {
+        guard !isPreview else { return }
+        let url = WidgetDeepLinkURL.resolved(base: meetBase, actionPath: path, accountSlot: 0)
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    private func openPrivacyCalendars() {
+        guard !isPreview else { return }
+        if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") {
+            NSWorkspace.shared.open(url)
+        }
+    }
+}
+
+// MARK: - EventKit
+
+@MainActor
+private final class MeetWidgetEventsModel: ObservableObject {
+    @Published private(set) var nextMeet: MeetMeetingSnapshot?
+    @Published private(set) var lastMeet: MeetMeetingSnapshot?
+    @Published private(set) var upcomingMeets: [MeetMeetingSnapshot] = []
+    @Published private(set) var recentMeets: [MeetMeetingSnapshot] = []
+    @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
+            clear()
+            return
+        }
+
+        let granted = await requestAccess()
+        accessDenied = !granted
+        if granted {
+            fetchAndPublish()
+        } else {
+            clear()
+        }
+    }
+
+    private func clear() {
+        nextMeet = nil
+        lastMeet = nil
+        upcomingMeets = []
+        recentMeets = []
+    }
+
+    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
+        guard let start = cal.date(byAdding: .day, value: -2, to: cal.startOfDay(for: now)),
+              let end = cal.date(byAdding: .day, value: 14, to: now) else {
+            clear()
+            return
+        }
+
+        let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil)
+        let events = store.events(matching: predicate)
+        let snapshots: [MeetMeetingSnapshot] = events.compactMap { Self.snapshot(from: $0) }
+
+        let upcoming = snapshots.filter { $0.end > now }.sorted { $0.start < $1.start }
+        nextMeet = upcoming.first
+        upcomingMeets = Array(upcoming.prefix(8))
+
+        let past = snapshots.filter { $0.end <= now }.sorted { $0.start > $1.start }
+        recentMeets = Array(past.prefix(6))
+        lastMeet = past.first
+    }
+
+    private static func snapshot(from event: EKEvent) -> MeetMeetingSnapshot? {
+        guard let url = extractMeetURL(from: event) else { return nil }
+        let raw = event.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        let title = raw.isEmpty ? "Meeting" : raw
+        return MeetMeetingSnapshot(
+            id: event.eventIdentifier,
+            title: title,
+            start: event.startDate,
+            end: event.endDate,
+            meetURL: url,
+            location: event.location
+        )
+    }
+
+    private static func extractMeetURL(from event: EKEvent) -> URL? {
+        let combined = [event.url?.absoluteString, event.location, event.notes]
+            .compactMap { $0 }
+            .joined(separator: "\n")
+        guard !combined.isEmpty else { return nil }
+        guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { return nil }
+        let range = NSRange(combined.startIndex..., in: combined)
+        for match in detector.matches(in: combined, options: [], range: range) {
+            guard let url = match.url, let host = url.host?.lowercased(), host.contains("meet.google.com") else { continue }
+            return url
+        }
+        return nil
+    }
+
+    private func loadSample() {
+        let cal = Calendar.current
+        let now = Date()
+        let soon = cal.date(byAdding: .hour, value: 1, to: now) ?? now
+        let soonEnd = cal.date(byAdding: .minute, value: 45, to: soon) ?? soon
+        let u = URL(string: "https://meet.google.com/abc-defg-hij")!
+
+        let n = MeetMeetingSnapshot(
+            id: "sample-next",
+            title: "Weekly sync",
+            start: soon,
+            end: soonEnd,
+            meetURL: u,
+            location: "Calendar"
+        )
+        nextMeet = n
+        upcomingMeets = [n]
+
+        let pastEnd = cal.date(byAdding: .hour, value: -1, to: now) ?? now
+        let pastStart = cal.date(byAdding: .hour, value: -2, to: now) ?? now
+        let last = MeetMeetingSnapshot(
+            id: "sample-last",
+            title: "Stand-up",
+            start: pastStart,
+            end: pastEnd,
+            meetURL: URL(string: "https://meet.google.com/zzz-yyyy-xxx")!,
+            location: nil
+        )
+        lastMeet = last
+        recentMeets = [last]
+    }
+
+    private static func canReadEvents(_ status: EKAuthorizationStatus) -> Bool {
+        switch status {
+        case .authorized:
+            return true
+        default:
+            if #available(macOS 14.0, *) {
+                return status == .fullAccess
+            }
+            return false
+        }
+    }
+}

+ 27 - 11
google_apps/Widgets/WidgetTemplates.swift

@@ -80,6 +80,15 @@ enum YoutubeWidgetMode: Hashable, Sendable {
     case interactiveNav
 }
 
+enum MeetWidgetMode: Hashable, Sendable {
+    /// Quick join next or last Meet link from Calendar.
+    case compact
+    /// Upcoming Meet-backed meetings with join buttons.
+    case agenda
+    /// Hub: details, schedule, recent links, shortcuts (opens Meet/Calendar in-app).
+    case hub
+}
+
 enum WidgetLayoutMode: Hashable {
     case iconOnly(title: String)
     case actionsRow
@@ -106,6 +115,8 @@ enum WidgetLayoutMode: Hashable {
     case travel(TravelWidgetMode)
     /// YouTube: medium search bar or large navigation shortcuts; opens in-app browser.
     case youtube(YoutubeWidgetMode)
+    /// Google Meet: Calendar-backed Meet links; join/create via in-app browser.
+    case meet(MeetWidgetMode)
 }
 
 struct WidgetVariant: Identifiable, Hashable {
@@ -312,17 +323,9 @@ enum WidgetTemplates {
             ]
         case .meet:
             return [
-                v("meet_small", "Meet", .small, false, .actionsRow, [
-                    a("join", "Join", "video.badge.plus", "/"),
-                    a("new", "New", "plus.circle", "/new"),
-                    a("landing", "Home", "house", "/landing"),
-                ]),
-                v("meet_large", "Meet Hub", .large, true, .actionsGrid(columns: 2), [
-                    a("new", "New meeting", "video.badge.plus", "/new"),
-                    a("join", "Join", "person.wave.2", "/"),
-                    a("landing", "Home", "house", "/landing"),
-                    a("schedule", "Schedule", "calendar", "https://calendar.google.com/calendar/u/0/r/eventedit"),
-                ]),
+                v("meet_small", "Quick join", .small, false, .meet(.compact), []),
+                v("meet_medium", "Upcoming", .medium, false, .meet(.agenda), []),
+                v("meet_large", "Meet hub", .large, false, .meet(.hub), []),
             ]
         case .contacts:
             return [
@@ -590,6 +593,7 @@ extension WidgetLayoutMode {
         case .travel(.interactive): true
         case .youtube(.searchBar): true
         case .youtube(.interactiveNav): true
+        case .meet: false
         case .calendar: false
         default: false
         }
@@ -634,6 +638,12 @@ extension WidgetLayoutMode {
             return CGSize(width: 400, height: 118)
         case .youtube(.interactiveNav):
             return CGSize(width: 420, height: 292)
+        case .meet(let mode):
+            switch mode {
+            case .compact: return CGSize(width: 240, height: 220)
+            case .agenda: return CGSize(width: 400, height: 280)
+            case .hub: return CGSize(width: 440, height: 520)
+            }
         default:
             return nil
         }
@@ -677,6 +687,12 @@ extension WidgetLayoutMode {
             return CGSize(width: 360, height: 108)
         case .youtube(.interactiveNav):
             return CGSize(width: 380, height: 268)
+        case .meet(let mode):
+            switch mode {
+            case .compact: return CGSize(width: 210, height: 200)
+            case .agenda: return CGSize(width: 360, height: 250)
+            case .hub: return CGSize(width: 380, height: 480)
+            }
         default:
             return nil
         }