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