Ver código fonte

Add Google Meet desktop widget system with sidebar integration.

Ports the widget architecture to meetings_app, including floating desktop panels, persisted widget instances, and in-app widget management wired to schedule snapshots and navigation actions.

Made-with: Cursor
huzaifahayat12 1 mês atrás
pai
commit
02f7340c1c

+ 1 - 0
meetings_app/AppDelegate.swift

@@ -20,6 +20,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
20 20
         NSApp.appearance = NSAppearance(named: darkEnabled ? .darkAqua : .aqua)
21 21
         UNUserNotificationCenter.current().delegate = self
22 22
         MeetingReminderManager.shared.requestPermissionIfNeeded()
23
+        DesktopWidgetWindowManager.shared.restore()
23 24
     }
24 25
 
25 26
     func applicationWillTerminate(_ aNotification: Notification) {

+ 81 - 5
meetings_app/ViewController.swift

@@ -15,7 +15,8 @@ private enum SidebarPage: Int {
15 15
     case joinMeetings = 0
16 16
     case photo = 1
17 17
     case video = 2
18
-    case settings = 3
18
+    case widgets = 3
19
+    case settings = 4
19 20
 }
20 21
 
21 22
 private enum ZoomJoinMode: Int {
@@ -353,6 +354,7 @@ final class ViewController: NSViewController {
353 354
     private var scheduleProfileImageTask: Task<Void, Never>?
354 355
     private var googleAccountPopover: NSPopover?
355 356
     private var scheduleCachedMeetings: [ScheduledMeeting] = []
357
+    private let widgetSnapshotLimit: Int = 3
356 358
 
357 359
     private var schedulePageFilter: SchedulePageFilter = .all
358 360
     private var schedulePageFromDate: Date = Calendar.current.startOfDay(for: Date())
@@ -446,6 +448,7 @@ final class ViewController: NSViewController {
446 448
         migrateLegacyRatingStateIfNeeded()
447 449
         beginUsageTrackingSessionIfNeeded()
448 450
         observeAppLifecycleForUsageTrackingIfNeeded()
451
+        registerWidgetNotificationObservers()
449 452
         setupRootView()
450 453
         buildMainLayout()
451 454
         showLaunchSplashIfNeeded()
@@ -454,6 +457,7 @@ final class ViewController: NSViewController {
454 457
 
455 458
     override func viewDidAppear() {
456 459
         super.viewDidAppear()
460
+        DesktopWidgetWindowManager.shared.restore()
457 461
         hasViewAppearedOnce = true
458 462
         presentLaunchPaywallIfNeeded()
459 463
         dismissLaunchSplashIfReady()
@@ -494,9 +498,7 @@ final class ViewController: NSViewController {
494 498
         endUsageTrackingSession()
495 499
         launchSplashTimeoutWorkItem?.cancel()
496 500
         launchSplashMinimumDelayWorkItem?.cancel()
497
-        if hasObservedAppLifecycleForUsage {
498
-            NotificationCenter.default.removeObserver(self)
499
-        }
501
+        NotificationCenter.default.removeObserver(self)
500 502
         if let observer = schedulePageScrollObservation {
501 503
             NotificationCenter.default.removeObserver(observer)
502 504
         }
@@ -685,6 +687,35 @@ private extension ViewController {
685 687
         showSidebarPage(page)
686 688
     }
687 689
 
690
+    private func registerWidgetNotificationObservers() {
691
+        NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenJoinMeetingsPageRequested), name: .widgetOpenJoinMeetingsPage, object: nil)
692
+        NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenSchedulePageRequested), name: .widgetOpenSchedulePage, object: nil)
693
+        NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenCalendarPageRequested), name: .widgetOpenCalendarPage, object: nil)
694
+        NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenSettingsPageRequested), name: .widgetOpenSettingsPage, object: nil)
695
+        NotificationCenter.default.addObserver(self, selector: #selector(widgetSignInRequested), name: .widgetSignInRequested, object: nil)
696
+        NotificationCenter.default.addObserver(self, selector: #selector(widgetRefreshRequested), name: .widgetRefreshRequested, object: nil)
697
+        NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenMeetWebRequested), name: .widgetOpenMeetWebRequested, object: nil)
698
+        NotificationCenter.default.addObserver(self, selector: #selector(widgetOpenMeetingLinkRequested(_:)), name: .widgetOpenMeetingLinkRequested, object: nil)
699
+    }
700
+
701
+    @objc private func widgetOpenJoinMeetingsPageRequested() { showSidebarPage(.joinMeetings) }
702
+    @objc private func widgetOpenSchedulePageRequested() { showSidebarPage(.photo) }
703
+    @objc private func widgetOpenCalendarPageRequested() { showSidebarPage(.video) }
704
+    @objc private func widgetOpenSettingsPageRequested() { showSidebarPage(.settings) }
705
+    @objc private func widgetSignInRequested() { scheduleConnectClicked() }
706
+    @objc private func widgetRefreshRequested() { scheduleReloadClicked() }
707
+
708
+    @objc private func widgetOpenMeetWebRequested() {
709
+        guard let url = URL(string: "https://meet.google.com/") else { return }
710
+        openInDefaultBrowser(url: url)
711
+    }
712
+
713
+    @objc private func widgetOpenMeetingLinkRequested(_ notification: Notification) {
714
+        guard let link = notification.userInfo?["link"] as? String,
715
+              let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
716
+        openInDefaultBrowser(url: url)
717
+    }
718
+
688 719
     @objc private func joinMeetClicked(_ sender: Any?) {
689 720
         let rawInput = meetLinkField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
690 721
 
@@ -1558,6 +1589,7 @@ private extension ViewController {
1558 1589
         pageCache[.joinMeetings] = nil
1559 1590
         pageCache[.photo] = nil
1560 1591
         pageCache[.video] = nil
1592
+        pageCache[.widgets] = nil
1561 1593
         pageCache[.settings] = nil
1562 1594
         showSidebarPage(selectedSidebarPage)
1563 1595
     }
@@ -1817,6 +1849,8 @@ private extension ViewController {
1817 1849
             built = makeSchedulePageContent()
1818 1850
         case .video:
1819 1851
             built = makeCalendarPageContent()
1852
+        case .widgets:
1853
+            built = makeWidgetsPageContent()
1820 1854
         case .settings:
1821 1855
             built = makeSettingsPageContent()
1822 1856
         }
@@ -1824,6 +1858,12 @@ private extension ViewController {
1824 1858
         return built
1825 1859
     }
1826 1860
 
1861
+    private func makeWidgetsPageContent() -> NSView {
1862
+        makeWidgetsPageHost(canAddWidgets: storeKitCoordinator.hasPremiumAccess) { [weak self] in
1863
+            self?.showPaywall()
1864
+        }
1865
+    }
1866
+
1827 1867
     private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
1828 1868
         let panel = NSView()
1829 1869
         panel.translatesAutoresizingMaskIntoConstraints = false
@@ -2308,6 +2348,8 @@ private extension ViewController {
2308 2348
             title = "Schedule"
2309 2349
         case .video:
2310 2350
             title = "Calendar"
2351
+        case .widgets:
2352
+            title = "Widgets"
2311 2353
         case .settings:
2312 2354
             title = "Settings"
2313 2355
         }
@@ -2330,7 +2372,7 @@ private extension ViewController {
2330 2372
     private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
2331 2373
         switch page {
2332 2374
         case .photo: return false
2333
-        case .joinMeetings, .video, .settings: return true
2375
+        case .joinMeetings, .video, .widgets, .settings: return true
2334 2376
         }
2335 2377
     }
2336 2378
 
@@ -2389,6 +2431,9 @@ private extension ViewController {
2389 2431
         let videoRow = sidebarItem("Calendar", icon: "􀎚", page: .video, systemSymbolName: "calendar")
2390 2432
         menuStack.addArrangedSubview(videoRow)
2391 2433
         sidebarRowViews[.video] = videoRow
2434
+        let widgetsRow = sidebarItem("Widgets", icon: "􀏅", page: .widgets, systemSymbolName: "square.grid.2x2.fill")
2435
+        menuStack.addArrangedSubview(widgetsRow)
2436
+        sidebarRowViews[.widgets] = widgetsRow
2392 2437
         menuStack.addArrangedSubview(sidebarSectionTitle("Additional"))
2393 2438
         let settingsRow = sidebarItem("Settings", icon: "􀍟", page: .settings, systemSymbolName: "gearshape.fill", logoHeightMultiplier: 1, showsDisclosure: true)
2394 2439
         menuStack.addArrangedSubview(settingsRow)
@@ -6725,6 +6770,8 @@ private extension ViewController {
6725 6770
                         renderScheduleCards(into: stack, meetings: [])
6726 6771
                     }
6727 6772
                     scheduleCachedMeetings = []
6773
+                    publishWidgetMeetingsSnapshot(from: [])
6774
+                    DesktopWidgetWindowManager.shared.refreshForAuthStateChange()
6728 6775
                     MeetingReminderManager.shared.cancelAllReminders()
6729 6776
                     applySchedulePageFiltersAndRender()
6730 6777
                     if calendarPageGridStack != nil {
@@ -6749,6 +6796,8 @@ private extension ViewController {
6749 6796
                     renderScheduleCards(into: stack, meetings: filtered)
6750 6797
                 }
6751 6798
                 scheduleCachedMeetings = meetings
6799
+                publishWidgetMeetingsSnapshot(from: filtered)
6800
+                DesktopWidgetWindowManager.shared.refreshForAuthStateChange()
6752 6801
                 if storeKitCoordinator.hasPremiumAccess {
6753 6802
                     MeetingReminderManager.shared.scheduleReminders(for: meetings)
6754 6803
                 }
@@ -6771,6 +6820,8 @@ private extension ViewController {
6771 6820
                     renderScheduleCards(into: stack, meetings: [])
6772 6821
                 }
6773 6822
                 scheduleCachedMeetings = []
6823
+                publishWidgetMeetingsSnapshot(from: [])
6824
+                DesktopWidgetWindowManager.shared.refreshForAuthStateChange()
6774 6825
                 MeetingReminderManager.shared.cancelAllReminders()
6775 6826
                 applySchedulePageFiltersAndRender()
6776 6827
                 if calendarPageGridStack != nil {
@@ -6783,6 +6834,26 @@ private extension ViewController {
6783 6834
         }
6784 6835
     }
6785 6836
 
6837
+    private func publishWidgetMeetingsSnapshot(from meetings: [ScheduledMeeting]) {
6838
+        let formatter = DateFormatter()
6839
+        formatter.dateFormat = "EEE, h:mm a"
6840
+        formatter.locale = Locale.current
6841
+        let payload = meetings.prefix(widgetSnapshotLimit).map { meeting in
6842
+            let endText = DateFormatter.localizedString(from: meeting.endDate, dateStyle: .none, timeStyle: .short)
6843
+            return WidgetMeetingSnapshot(
6844
+                id: meeting.id,
6845
+                title: meeting.title,
6846
+                timeText: "\(formatter.string(from: meeting.startDate)) - \(endText)",
6847
+                joinLink: meeting.meetURL.absoluteString
6848
+            )
6849
+        }
6850
+        if let data = try? JSONEncoder().encode(payload) {
6851
+            UserDefaults.standard.set(data, forKey: WidgetMeetingStore.key)
6852
+        } else {
6853
+            UserDefaults.standard.removeObject(forKey: WidgetMeetingStore.key)
6854
+        }
6855
+    }
6856
+
6786 6857
     func showScheduleHelp() {
6787 6858
         let alert = NSAlert()
6788 6859
         alert.messageText = "Google schedule"
@@ -6801,6 +6872,7 @@ private extension ViewController {
6801 6872
                     self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
6802 6873
                     self.pageCache[.joinMeetings] = nil
6803 6874
                     self.pageCache[.photo] = nil
6875
+                    self.pageCache[.widgets] = nil
6804 6876
                     self.showSidebarPage(self.selectedSidebarPage)
6805 6877
                 }
6806 6878
                 await self.loadSchedule()
@@ -6830,6 +6902,7 @@ private extension ViewController {
6830 6902
                     self.pageCache[.joinMeetings] = nil
6831 6903
                     self.pageCache[.photo] = nil
6832 6904
                     self.pageCache[.video] = nil
6905
+                    self.pageCache[.widgets] = nil
6833 6906
                     self.pageCache[.settings] = nil
6834 6907
                     self.showSidebarPage(self.selectedSidebarPage)
6835 6908
                 }
@@ -6878,10 +6951,13 @@ private extension ViewController {
6878 6951
             MeetingReminderManager.shared.cancelAllReminders()
6879 6952
             try googleOAuth.signOut()
6880 6953
             applyGoogleProfile(nil)
6954
+            publishWidgetMeetingsSnapshot(from: [])
6955
+            DesktopWidgetWindowManager.shared.refreshForAuthStateChange()
6881 6956
             updateGoogleAuthButtonTitle()
6882 6957
             pageCache[.joinMeetings] = nil
6883 6958
             pageCache[.photo] = nil
6884 6959
             pageCache[.video] = nil
6960
+            pageCache[.widgets] = nil
6885 6961
             pageCache[.settings] = nil
6886 6962
             showSidebarPage(selectedSidebarPage)
6887 6963
             Task { [weak self] in

+ 371 - 0
meetings_app/Widgets/DesktopWidgetView.swift

@@ -0,0 +1,371 @@
1
+import SwiftUI
2
+import AppKit
3
+
4
+struct DesktopWidgetView: View {
5
+    let variant: WidgetVariant
6
+    var isPreview: Bool
7
+    private let authService = GoogleOAuthService.shared
8
+
9
+    private var isSignedIn: Bool {
10
+        if isPreview { return true }
11
+        return authService.loadTokens() != nil
12
+    }
13
+
14
+    private var topMeetings: [WidgetMeetingSnapshot] {
15
+        WidgetMeetingStore.load().prefix(3).map { $0 }
16
+    }
17
+
18
+    var body: some View {
19
+        ZStack {
20
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
21
+                .fill(
22
+                    LinearGradient(
23
+                        colors: [
24
+                            Color(red: 0.08, green: 0.48, blue: 0.86),
25
+                            Color(red: 0.09, green: 0.66, blue: 0.46)
26
+                        ],
27
+                        startPoint: .topLeading,
28
+                        endPoint: .bottomTrailing
29
+                    )
30
+                )
31
+                .overlay(
32
+                    RoundedRectangle(cornerRadius: 22, style: .continuous)
33
+                        .stroke(Color.white.opacity(0.14), lineWidth: 1)
34
+                )
35
+
36
+            VStack(alignment: .leading, spacing: 12) {
37
+                widgetHeader
38
+                contentBody
39
+                if variant.size != .small {
40
+                    Spacer(minLength: 0)
41
+                }
42
+            }
43
+            .padding(.horizontal, 14)
44
+            .padding(.bottom, variant.size == .medium ? 30 : 14)
45
+            .padding(.top, variant.size == .medium ? 34 : (variant.size == .small ? 10 : 14))
46
+        }
47
+    }
48
+
49
+    @ViewBuilder
50
+    private var contentBody: some View {
51
+        if !isSignedIn {
52
+            loggedOutContent
53
+        } else {
54
+            switch variant.size {
55
+            case .small:
56
+                VStack(alignment: .leading, spacing: 10) {
57
+                    Text("Quick actions")
58
+                        .font(.system(size: 11.5, weight: .semibold))
59
+                        .foregroundStyle(.white.opacity(0.78))
60
+                    compactActionButton(title: "Open Meet", icon: "video.fill", destination: .openMeetWeb)
61
+                    HStack(spacing: 8) {
62
+                        compactActionButton(title: "Schedule", icon: "clock.badge.checkmark", destination: .schedule)
63
+                        compactActionButton(title: "Settings", icon: "gearshape.fill", destination: .settings)
64
+                    }
65
+                }
66
+            case .medium:
67
+                meetingBlock(maxRows: 2)
68
+                LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 0), spacing: 8), count: 2), spacing: 8) {
69
+                    ForEach(variant.quickActions.prefix(4)) { action in
70
+                        actionTile(action)
71
+                    }
72
+                }
73
+            case .large:
74
+                meetingBlock(maxRows: 3)
75
+                Divider().overlay(Color.white.opacity(0.18))
76
+                LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 0), spacing: 8), count: 2), spacing: 8) {
77
+                    ForEach(variant.quickActions.prefix(6)) { action in
78
+                        actionTile(action)
79
+                    }
80
+                }
81
+            }
82
+        }
83
+    }
84
+
85
+    private var widgetHeader: some View {
86
+        HStack(spacing: 10) {
87
+            Image("HeaderLogo")
88
+                .resizable()
89
+                .scaledToFit()
90
+                .frame(width: 30, height: 30)
91
+                .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
92
+            VStack(alignment: .leading, spacing: 2) {
93
+                Text(variant.title)
94
+                    .font(.system(size: 16, weight: .bold))
95
+                    .foregroundStyle(.white.opacity(0.95))
96
+                Text(variant.subtitle)
97
+                    .font(.system(size: 12, weight: .medium))
98
+                    .foregroundStyle(.white.opacity(0.8))
99
+            }
100
+            Spacer(minLength: 0)
101
+        }
102
+    }
103
+
104
+    private func meetingBlock(maxRows: Int) -> some View {
105
+        VStack(alignment: .leading, spacing: 9) {
106
+            Text("Upcoming meetings")
107
+                .font(.system(size: 11.5, weight: .semibold))
108
+                .foregroundStyle(.white.opacity(0.80))
109
+            if topMeetings.isEmpty {
110
+                Text("No upcoming meetings")
111
+                    .font(.system(size: 12, weight: .medium))
112
+                    .foregroundStyle(.white.opacity(0.82))
113
+            } else {
114
+                VStack(alignment: .leading, spacing: 8) {
115
+                    ForEach(topMeetings.prefix(maxRows)) { meeting in
116
+                        meetingRow(meeting)
117
+                    }
118
+                }
119
+            }
120
+        }
121
+    }
122
+
123
+    @ViewBuilder
124
+    private var loggedOutContent: some View {
125
+        VStack(alignment: .leading, spacing: 10) {
126
+            Text("Connect Google to use widget actions.")
127
+                .font(.system(size: 12, weight: .medium))
128
+                .foregroundStyle(.white.opacity(0.86))
129
+            Button(action: { open(action: WidgetQuickAction(id: "login", title: "Login", systemImage: "person.crop.circle.badge.checkmark", destination: .signIn)) }) {
130
+                HStack(spacing: 8) {
131
+                    Image(systemName: "person.crop.circle.badge.checkmark")
132
+                        .font(.system(size: 12, weight: .semibold))
133
+                    Text("Sign in with Google")
134
+                        .font(.system(size: 12.5, weight: .bold))
135
+                    Spacer(minLength: 0)
136
+                }
137
+                .foregroundStyle(.white.opacity(0.95))
138
+                .padding(.horizontal, 12)
139
+                .padding(.vertical, 10)
140
+                .background(
141
+                    RoundedRectangle(cornerRadius: 12, style: .continuous)
142
+                        .fill(Color.black.opacity(0.24))
143
+                )
144
+                .overlay(
145
+                    RoundedRectangle(cornerRadius: 12, style: .continuous)
146
+                        .stroke(Color.white.opacity(0.16), lineWidth: 1)
147
+                )
148
+            }
149
+            .buttonStyle(.plain)
150
+            .disabled(isPreview)
151
+            .allowsHitTesting(!isPreview)
152
+        }
153
+    }
154
+
155
+    private func meetingRow(_ meeting: WidgetMeetingSnapshot) -> some View {
156
+        Button(action: {
157
+            if let link = meeting.joinLink?.trimmingCharacters(in: .whitespacesAndNewlines), !link.isEmpty {
158
+                WidgetAppNavigator.openMeetingLink(link)
159
+            } else {
160
+                WidgetAppNavigator.open(target: .schedule)
161
+            }
162
+        }) {
163
+            HStack(spacing: 8) {
164
+                Image(systemName: "video.fill")
165
+                    .font(.system(size: 11, weight: .semibold))
166
+                    .foregroundStyle(.white.opacity(0.88))
167
+                VStack(alignment: .leading, spacing: 1) {
168
+                    Text(meeting.title)
169
+                        .font(.system(size: 11.5, weight: .semibold))
170
+                        .foregroundStyle(.white.opacity(0.93))
171
+                        .lineLimit(1)
172
+                    Text(meeting.timeText)
173
+                        .font(.system(size: 10.5, weight: .medium))
174
+                        .foregroundStyle(.white.opacity(0.74))
175
+                        .lineLimit(1)
176
+                }
177
+                Spacer(minLength: 0)
178
+            }
179
+            .padding(.horizontal, 10)
180
+            .padding(.vertical, 7)
181
+            .background(
182
+                RoundedRectangle(cornerRadius: 10, style: .continuous)
183
+                    .fill(Color.black.opacity(0.20))
184
+            )
185
+            .overlay(
186
+                RoundedRectangle(cornerRadius: 10, style: .continuous)
187
+                    .stroke(Color.white.opacity(0.08), lineWidth: 1)
188
+            )
189
+        }
190
+        .buttonStyle(.plain)
191
+        .disabled(isPreview)
192
+        .allowsHitTesting(!isPreview)
193
+    }
194
+
195
+    private func compactActionButton(title: String, icon: String, destination: WidgetQuickAction.Destination) -> some View {
196
+        Button(action: { WidgetAppNavigator.open(target: destination) }) {
197
+            HStack(spacing: 5) {
198
+                Image(systemName: icon)
199
+                    .font(.system(size: 10.5, weight: .semibold))
200
+                    .frame(width: 14)
201
+                Text(title)
202
+                    .font(.system(size: 11.5, weight: .bold))
203
+                    .lineLimit(1)
204
+                    .minimumScaleFactor(0.82)
205
+                    .layoutPriority(1)
206
+            }
207
+            .foregroundStyle(.white.opacity(0.97))
208
+            .padding(.horizontal, 10)
209
+            .padding(.vertical, 8)
210
+            .frame(maxWidth: .infinity)
211
+            .background(
212
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
213
+                    .fill(Color.black.opacity(0.30))
214
+            )
215
+            .overlay(
216
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
217
+                    .stroke(Color.white.opacity(0.24), lineWidth: 1)
218
+            )
219
+        }
220
+        .buttonStyle(.plain)
221
+        .disabled(isPreview)
222
+        .allowsHitTesting(!isPreview)
223
+    }
224
+
225
+    private func actionTile(_ action: WidgetQuickAction) -> some View {
226
+        Button(action: { open(action: action) }) {
227
+            HStack(spacing: 8) {
228
+                Image(systemName: action.systemImage)
229
+                    .font(.system(size: 11, weight: .semibold))
230
+                Text(action.title)
231
+                    .font(.system(size: 12, weight: .bold))
232
+                    .lineLimit(1)
233
+                Spacer(minLength: 0)
234
+            }
235
+            .foregroundStyle(.white.opacity(0.92))
236
+            .padding(.horizontal, 10)
237
+            .padding(.vertical, 9)
238
+            .background(
239
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
240
+                    .fill(Color.black.opacity(0.24))
241
+            )
242
+            .overlay(
243
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
244
+                    .stroke(Color.white.opacity(0.10), lineWidth: 1)
245
+            )
246
+        }
247
+        .buttonStyle(.plain)
248
+        .disabled(isPreview)
249
+        .allowsHitTesting(!isPreview)
250
+    }
251
+
252
+    private func open(action: WidgetQuickAction) {
253
+        guard !isPreview else { return }
254
+        WidgetAppNavigator.open(target: action.destination)
255
+    }
256
+}
257
+
258
+struct WidgetMeetingSnapshot: Codable, Identifiable {
259
+    let id: String
260
+    let title: String
261
+    let timeText: String
262
+    let joinLink: String?
263
+}
264
+
265
+enum WidgetMeetingStore {
266
+    static let key = "meetings.widget.topMeetings"
267
+
268
+    static func load() -> [WidgetMeetingSnapshot] {
269
+        guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
270
+        return (try? JSONDecoder().decode([WidgetMeetingSnapshot].self, from: data)) ?? []
271
+    }
272
+}
273
+
274
+enum WidgetAppNavigator {
275
+    static func open(target: WidgetQuickAction.Destination) {
276
+        DispatchQueue.main.async {
277
+            if target == .refreshMeetings {
278
+                postTarget(target, delay: 0)
279
+                return
280
+            }
281
+            if target == .signIn {
282
+                postTarget(target, delay: 0)
283
+                return
284
+            }
285
+
286
+            bringAppToFront()
287
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
288
+                bringAppToFront()
289
+            }
290
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.42) {
291
+                bringAppToFront()
292
+            }
293
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.60) {
294
+                activateApplicationFallback()
295
+                bringAppToFront()
296
+            }
297
+            postTarget(target, delay: 0.30)
298
+        }
299
+    }
300
+
301
+    static func openMeetingLink(_ link: String) {
302
+        DispatchQueue.main.async {
303
+            bringAppToFront()
304
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.20) {
305
+                bringAppToFront()
306
+            }
307
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
308
+                NotificationCenter.default.post(
309
+                    name: .widgetOpenMeetingLinkRequested,
310
+                    object: nil,
311
+                    userInfo: ["link": link]
312
+                )
313
+            }
314
+        }
315
+    }
316
+
317
+    private static func bringAppToFront() {
318
+        NSApp.unhide(nil)
319
+        NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
320
+        NSApp.activate(ignoringOtherApps: true)
321
+        NSApp.arrangeInFront(nil)
322
+        for window in NSApp.windows where window.contentViewController is ViewController {
323
+            if window.isMiniaturized {
324
+                window.deminiaturize(nil)
325
+            }
326
+            window.orderFrontRegardless()
327
+            window.makeKeyAndOrderFront(nil)
328
+        }
329
+    }
330
+
331
+    private static func activateApplicationFallback() {
332
+        let bundleID = Bundle.main.bundleIdentifier ?? "com.mqldev.meetingsapp"
333
+        let scriptSource = "tell application id \"\(bundleID)\" to activate"
334
+        if let script = NSAppleScript(source: scriptSource) {
335
+            var errorInfo: NSDictionary?
336
+            script.executeAndReturnError(&errorInfo)
337
+        }
338
+    }
339
+
340
+    private static func postTarget(_ target: WidgetQuickAction.Destination, delay: TimeInterval) {
341
+        DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
342
+            switch target {
343
+            case .joinMeetings:
344
+                NotificationCenter.default.post(name: .widgetOpenJoinMeetingsPage, object: nil)
345
+            case .schedule:
346
+                NotificationCenter.default.post(name: .widgetOpenSchedulePage, object: nil)
347
+            case .calendar:
348
+                NotificationCenter.default.post(name: .widgetOpenCalendarPage, object: nil)
349
+            case .settings:
350
+                NotificationCenter.default.post(name: .widgetOpenSettingsPage, object: nil)
351
+            case .signIn:
352
+                NotificationCenter.default.post(name: .widgetSignInRequested, object: nil)
353
+            case .openMeetWeb:
354
+                NotificationCenter.default.post(name: .widgetOpenMeetWebRequested, object: nil)
355
+            case .refreshMeetings:
356
+                NotificationCenter.default.post(name: .widgetRefreshRequested, object: nil)
357
+            }
358
+        }
359
+    }
360
+}
361
+
362
+extension Notification.Name {
363
+    static let widgetOpenJoinMeetingsPage = Notification.Name("widgetOpenJoinMeetingsPage")
364
+    static let widgetOpenSchedulePage = Notification.Name("widgetOpenSchedulePage")
365
+    static let widgetOpenCalendarPage = Notification.Name("widgetOpenCalendarPage")
366
+    static let widgetOpenSettingsPage = Notification.Name("widgetOpenSettingsPage")
367
+    static let widgetSignInRequested = Notification.Name("widgetSignInRequested")
368
+    static let widgetOpenMeetWebRequested = Notification.Name("widgetOpenMeetWebRequested")
369
+    static let widgetRefreshRequested = Notification.Name("widgetRefreshRequested")
370
+    static let widgetOpenMeetingLinkRequested = Notification.Name("widgetOpenMeetingLinkRequested")
371
+}

+ 283 - 0
meetings_app/Widgets/DesktopWidgetWindowManager.swift

@@ -0,0 +1,283 @@
1
+import AppKit
2
+import SwiftUI
3
+import Combine
4
+
5
+final class DesktopWidgetWindowManager: ObservableObject {
6
+    static let shared = DesktopWidgetWindowManager()
7
+
8
+    private let storageKey = "meetings.widget.instancesData"
9
+    private var windowsByInstanceID: [UUID: NSPanel] = [:]
10
+    @Published private(set) var instancesChangeToken: Int = 0
11
+    @Published private(set) var authStateRefreshToken: Int = 0
12
+
13
+    private init() {}
14
+
15
+    private struct StoredWidgetMeetingSnapshot: Decodable {
16
+        let id: String
17
+    }
18
+
19
+    func hasInstance(variantID: String) -> Bool {
20
+        loadInstances().contains { $0.variantID == variantID }
21
+    }
22
+
23
+    func show(instance: WidgetInstance) {
24
+        let variant = WidgetTemplates.variant(for: instance.variantID)
25
+        var resolved = instance
26
+        if resolved.origin == nil {
27
+            resolved.origin = nextAutoPlacement(for: variant)
28
+        }
29
+
30
+        let panel = makePanel(for: resolved, variant: variant)
31
+        windowsByInstanceID[resolved.id] = panel
32
+        persistUpsert(instance: resolved)
33
+        if let origin = resolved.origin {
34
+            panel.setFrameOrigin(NSPoint(x: origin.x, y: origin.y))
35
+            persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
36
+        } else {
37
+            panel.center()
38
+            persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
39
+        }
40
+        panel.orderFront(nil)
41
+        panel.orderFrontRegardless()
42
+        instancesChangeToken += 1
43
+    }
44
+
45
+    @discardableResult
46
+    func removeInstances(variantID: String) -> Int {
47
+        let instances = loadInstances()
48
+        let removed = instances.filter { $0.variantID == variantID }
49
+        let remaining = instances.filter { $0.variantID != variantID }
50
+        for instance in removed {
51
+            if let panel = windowsByInstanceID.removeValue(forKey: instance.id) {
52
+                panel.close()
53
+            }
54
+        }
55
+        saveInstances(remaining)
56
+        instancesChangeToken += 1
57
+        return removed.count
58
+    }
59
+
60
+    func restore() {
61
+        for instance in loadInstances() where windowsByInstanceID[instance.id] == nil {
62
+            show(instance: instance)
63
+        }
64
+    }
65
+
66
+    func refreshForAuthStateChange() {
67
+        resizeVisiblePanelsForCurrentContent()
68
+        authStateRefreshToken += 1
69
+    }
70
+
71
+    private func makePanel(for instance: WidgetInstance, variant: WidgetVariant) -> NSPanel {
72
+        let size = panelSize(for: variant.size)
73
+        let panel = DesktopWidgetPanel(
74
+            contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
75
+            styleMask: [.nonactivatingPanel, .borderless],
76
+            backing: .buffered,
77
+            defer: false
78
+        )
79
+        panel.becomesKeyOnlyIfNeeded = false
80
+        panel.isReleasedWhenClosed = false
81
+        panel.isFloatingPanel = false
82
+        panel.isMovable = true
83
+        panel.hasShadow = false
84
+        panel.isOpaque = false
85
+        panel.backgroundColor = .clear
86
+        panel.isMovableByWindowBackground = true
87
+        panel.hidesOnDeactivate = false
88
+        panel.ignoresMouseEvents = false
89
+        let desktopIconLevel = Int(CGWindowLevelForKey(.desktopIconWindow))
90
+        panel.level = NSWindow.Level(rawValue: desktopIconLevel + 1)
91
+        panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
92
+
93
+        let host = NSHostingView(rootView: DesktopWidgetHostView(instanceID: instance.id, variant: variant))
94
+        host.translatesAutoresizingMaskIntoConstraints = false
95
+        let container = NSView()
96
+        container.wantsLayer = true
97
+        container.layer?.backgroundColor = NSColor.clear.cgColor
98
+        container.layer?.cornerRadius = 22
99
+        container.layer?.masksToBounds = true
100
+        container.addSubview(host)
101
+        NSLayoutConstraint.activate([
102
+            host.leadingAnchor.constraint(equalTo: container.leadingAnchor),
103
+            host.trailingAnchor.constraint(equalTo: container.trailingAnchor),
104
+            host.topAnchor.constraint(equalTo: container.topAnchor),
105
+            host.bottomAnchor.constraint(equalTo: container.bottomAnchor)
106
+        ])
107
+        panel.contentView = container
108
+
109
+        NotificationCenter.default.addObserver(
110
+            forName: NSWindow.didMoveNotification,
111
+            object: panel,
112
+            queue: .main
113
+        ) { [weak self, weak panel] _ in
114
+            guard let self, let panel else { return }
115
+            self.persistOrigin(instanceID: instance.id, origin: panel.frame.origin)
116
+        }
117
+        return panel
118
+    }
119
+
120
+    private func panelSize(for size: WidgetSize) -> CGSize {
121
+        switch size {
122
+        case .small:
123
+            return CGSize(width: 220, height: 204)
124
+        case .medium:
125
+            return CGSize(width: 390, height: mediumPanelHeight())
126
+        case .large:
127
+            return CGSize(width: 390, height: 398)
128
+        }
129
+    }
130
+
131
+    private func mediumPanelHeight() -> CGFloat {
132
+        let visibleMeetingCount = min(loadWidgetMeetingCount(), 2)
133
+        switch visibleMeetingCount {
134
+        case 0:
135
+            return 212
136
+        case 1:
137
+            return 226
138
+        default:
139
+            return 240
140
+        }
141
+    }
142
+
143
+    private func loadWidgetMeetingCount() -> Int {
144
+        let key = WidgetMeetingStore.key
145
+        guard let data = UserDefaults.standard.data(forKey: key),
146
+              let meetings = try? JSONDecoder().decode([StoredWidgetMeetingSnapshot].self, from: data) else {
147
+            return 0
148
+        }
149
+        return meetings.count
150
+    }
151
+
152
+    private func resizeVisiblePanelsForCurrentContent() {
153
+        let instancesByID = Dictionary(uniqueKeysWithValues: loadInstances().map { ($0.id, $0) })
154
+        for (instanceID, panel) in windowsByInstanceID {
155
+            guard let instance = instancesByID[instanceID] else { continue }
156
+            let variant = WidgetTemplates.variant(for: instance.variantID)
157
+            let targetSize = panelSize(for: variant.size)
158
+            let currentSize = panel.frame.size
159
+            guard abs(currentSize.height - targetSize.height) > 0.5 || abs(currentSize.width - targetSize.width) > 0.5 else {
160
+                continue
161
+            }
162
+
163
+            var frame = panel.frame
164
+            let oldMaxY = frame.maxY
165
+            frame.size = targetSize
166
+            frame.origin.y = oldMaxY - targetSize.height
167
+            panel.setFrame(frame, display: true)
168
+            persistOrigin(instanceID: instanceID, origin: frame.origin)
169
+        }
170
+    }
171
+
172
+    private func nextAutoPlacement(for variant: WidgetVariant) -> WidgetInstance.Origin {
173
+        let size = panelSize(for: variant.size)
174
+        let frame = NSScreen.main?.visibleFrame ?? NSRect(x: 100, y: 100, width: 1200, height: 800)
175
+        let margin: CGFloat = 18
176
+        let gapX: CGFloat = 22
177
+        let gapY: CGFloat = 18
178
+        let cellW = size.width + gapX
179
+        let cellH = size.height + gapY
180
+        let existingRects = loadInstances().compactMap { instance -> NSRect? in
181
+            guard let origin = instance.origin else { return nil }
182
+            let instanceSize = panelSize(for: instance.size)
183
+            return NSRect(x: origin.x, y: origin.y, width: instanceSize.width, height: instanceSize.height)
184
+        }
185
+
186
+        let cols = max(1, Int((frame.width - margin * 2) / max(cellW, 1)))
187
+        let rows = max(1, Int((frame.height - margin * 2) / max(cellH, 1)))
188
+        let maxSlots = max(120, cols * rows)
189
+        for slot in 0..<maxSlots {
190
+            let row = slot / cols
191
+            let col = cols - 1 - (slot % cols)
192
+            let x = frame.minX + margin + CGFloat(col) * cellW
193
+            let y = frame.maxY - margin - size.height - CGFloat(row) * cellH
194
+            let candidate = NSRect(x: x, y: y, width: size.width, height: size.height)
195
+            guard candidate.minX >= frame.minX + margin,
196
+                  candidate.maxX <= frame.maxX - margin,
197
+                  candidate.minY >= frame.minY + margin else {
198
+                continue
199
+            }
200
+            if !existingRects.contains(where: { $0.intersects(candidate.insetBy(dx: -8, dy: -8)) }) {
201
+                return WidgetInstance.Origin(x: candidate.minX, y: candidate.minY)
202
+            }
203
+        }
204
+        return WidgetInstance.Origin(
205
+            x: frame.midX - size.width / 2,
206
+            y: frame.midY - size.height / 2
207
+        )
208
+    }
209
+
210
+    private func close(instanceID: UUID) {
211
+        if let panel = windowsByInstanceID.removeValue(forKey: instanceID) {
212
+            panel.close()
213
+        }
214
+        let remaining = loadInstances().filter { $0.id != instanceID }
215
+        saveInstances(remaining)
216
+        instancesChangeToken += 1
217
+    }
218
+
219
+    private func loadInstances() -> [WidgetInstance] {
220
+        guard let data = UserDefaults.standard.data(forKey: storageKey) else { return [] }
221
+        let decoded = (try? JSONDecoder().decode([WidgetInstance].self, from: data)) ?? []
222
+        let allowedByVariantID = Dictionary(uniqueKeysWithValues: WidgetTemplates.meetVariants.map { ($0.id, $0.size) })
223
+        let sanitized = decoded.compactMap { instance -> WidgetInstance? in
224
+            guard let expectedSize = allowedByVariantID[instance.variantID] else { return nil }
225
+            var normalized = instance
226
+            normalized.size = expectedSize
227
+            return normalized
228
+        }
229
+        if sanitized != decoded {
230
+            saveInstances(sanitized)
231
+        }
232
+        return sanitized
233
+    }
234
+
235
+    private func saveInstances(_ instances: [WidgetInstance]) {
236
+        guard let data = try? JSONEncoder().encode(instances) else { return }
237
+        UserDefaults.standard.set(data, forKey: storageKey)
238
+    }
239
+
240
+    private func persistUpsert(instance: WidgetInstance) {
241
+        var instances = loadInstances()
242
+        if let idx = instances.firstIndex(where: { $0.id == instance.id }) {
243
+            instances[idx] = instance
244
+        } else {
245
+            instances.append(instance)
246
+        }
247
+        saveInstances(instances)
248
+    }
249
+
250
+    private func persistOrigin(instanceID: UUID, origin: NSPoint) {
251
+        var instances = loadInstances()
252
+        guard let idx = instances.firstIndex(where: { $0.id == instanceID }) else { return }
253
+        instances[idx].origin = WidgetInstance.Origin(x: origin.x, y: origin.y)
254
+        saveInstances(instances)
255
+    }
256
+
257
+    private struct DesktopWidgetHostView: View {
258
+        let instanceID: UUID
259
+        let variant: WidgetVariant
260
+        @ObservedObject private var manager = DesktopWidgetWindowManager.shared
261
+
262
+        var body: some View {
263
+            DesktopWidgetView(variant: variant, isPreview: false)
264
+                .id(manager.authStateRefreshToken)
265
+                .contextMenu {
266
+                    Button("Remove Widget", role: .destructive) {
267
+                        DesktopWidgetWindowManager.shared.close(instanceID: instanceID)
268
+                    }
269
+                }
270
+        }
271
+    }
272
+}
273
+
274
+private final class DesktopWidgetPanel: NSPanel {
275
+    override var canBecomeKey: Bool { true }
276
+    override var canBecomeMain: Bool { false }
277
+
278
+    override func mouseDown(with event: NSEvent) {
279
+        NSApp.activate(ignoringOtherApps: true)
280
+        makeKeyAndOrderFront(nil)
281
+        super.mouseDown(with: event)
282
+    }
283
+}

+ 20 - 0
meetings_app/Widgets/WidgetInstance.swift

@@ -0,0 +1,20 @@
1
+import Foundation
2
+
3
+struct WidgetInstance: Identifiable, Codable, Hashable {
4
+    struct Origin: Codable, Hashable {
5
+        var x: Double
6
+        var y: Double
7
+    }
8
+
9
+    let id: UUID
10
+    let variantID: String
11
+    var size: WidgetSize
12
+    var origin: Origin?
13
+
14
+    init(id: UUID = UUID(), variantID: String, size: WidgetSize, origin: Origin?) {
15
+        self.id = id
16
+        self.variantID = variantID
17
+        self.size = size
18
+        self.origin = origin
19
+    }
20
+}

+ 20 - 0
meetings_app/Widgets/WidgetPreviewCard.swift

@@ -0,0 +1,20 @@
1
+import SwiftUI
2
+
3
+struct WidgetPreviewCard: View {
4
+    let variant: WidgetVariant
5
+    var layoutScale: CGFloat = 1
6
+
7
+    var body: some View {
8
+        let size = variant.previewCardSize
9
+        let scale = max(0.01, layoutScale)
10
+        DesktopWidgetView(variant: variant, isPreview: true)
11
+            .frame(width: size.width, height: size.height)
12
+            .scaleEffect(scale)
13
+            .frame(width: size.width * scale, height: size.height * scale)
14
+            .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
15
+            .overlay(
16
+                RoundedRectangle(cornerRadius: 18, style: .continuous)
17
+                    .stroke(Color.white.opacity(0.12), lineWidth: 1)
18
+            )
19
+    }
20
+}

+ 15 - 0
meetings_app/Widgets/WidgetSize.swift

@@ -0,0 +1,15 @@
1
+import Foundation
2
+
3
+enum WidgetSize: String, CaseIterable, Codable, Hashable {
4
+    case small
5
+    case medium
6
+    case large
7
+
8
+    var title: String {
9
+        switch self {
10
+        case .small: return "Small"
11
+        case .medium: return "Medium"
12
+        case .large: return "Large"
13
+        }
14
+    }
15
+}

+ 85 - 0
meetings_app/Widgets/WidgetTemplates.swift

@@ -0,0 +1,85 @@
1
+import Foundation
2
+import CoreGraphics
3
+
4
+struct WidgetQuickAction: Identifiable, Hashable {
5
+    enum Destination: String, Hashable {
6
+        case joinMeetings
7
+        case schedule
8
+        case calendar
9
+        case settings
10
+        case signIn
11
+        case openMeetWeb
12
+        case refreshMeetings
13
+    }
14
+
15
+    let id: String
16
+    let title: String
17
+    let systemImage: String
18
+    let destination: Destination
19
+}
20
+
21
+struct WidgetVariant: Identifiable, Hashable {
22
+    let id: String
23
+    let title: String
24
+    let subtitle: String
25
+    let size: WidgetSize
26
+    let quickActions: [WidgetQuickAction]
27
+}
28
+
29
+enum WidgetTemplates {
30
+    static let meetVariants: [WidgetVariant] = [
31
+        WidgetVariant(
32
+            id: "meet_small",
33
+            title: "Google Meet",
34
+            subtitle: "Open and join quickly",
35
+            size: .small,
36
+            quickActions: [
37
+                WidgetQuickAction(id: "open", title: "Open Meet", systemImage: "video.fill", destination: .openMeetWeb),
38
+                WidgetQuickAction(id: "join", title: "Join Meetings", systemImage: "arrow.up.forward.app.fill", destination: .joinMeetings)
39
+            ]
40
+        ),
41
+        WidgetVariant(
42
+            id: "meet_medium",
43
+            title: "Google Meet",
44
+            subtitle: "Upcoming meetings",
45
+            size: .medium,
46
+            quickActions: [
47
+                WidgetQuickAction(id: "schedule", title: "Schedule", systemImage: "clock.badge.checkmark", destination: .schedule),
48
+                WidgetQuickAction(id: "calendar", title: "Calendar", systemImage: "calendar", destination: .calendar),
49
+                WidgetQuickAction(id: "settings", title: "Settings", systemImage: "gearshape.fill", destination: .settings),
50
+                WidgetQuickAction(id: "refresh", title: "Refresh", systemImage: "arrow.clockwise", destination: .refreshMeetings)
51
+            ]
52
+        ),
53
+        WidgetVariant(
54
+            id: "meet_large",
55
+            title: "Google Meet",
56
+            subtitle: "Your day at a glance",
57
+            size: .large,
58
+            quickActions: [
59
+                WidgetQuickAction(id: "schedule", title: "Schedule", systemImage: "clock.badge.checkmark", destination: .schedule),
60
+                WidgetQuickAction(id: "calendar", title: "Calendar", systemImage: "calendar", destination: .calendar),
61
+                WidgetQuickAction(id: "settings", title: "Settings", systemImage: "gearshape.fill", destination: .settings),
62
+                WidgetQuickAction(id: "refresh", title: "Refresh", systemImage: "arrow.clockwise", destination: .refreshMeetings),
63
+                WidgetQuickAction(id: "meet", title: "Open Meet", systemImage: "video.fill", destination: .openMeetWeb),
64
+                WidgetQuickAction(id: "home", title: "Home", systemImage: "house.fill", destination: .joinMeetings)
65
+            ]
66
+        )
67
+    ]
68
+
69
+    static func variant(for variantID: String) -> WidgetVariant {
70
+        meetVariants.first(where: { $0.id == variantID }) ?? meetVariants[0]
71
+    }
72
+}
73
+
74
+extension WidgetVariant {
75
+    var previewCardSize: CGSize {
76
+        switch size {
77
+        case .small:
78
+            return CGSize(width: 196, height: 196)
79
+        case .medium:
80
+            return CGSize(width: 340, height: 220)
81
+        case .large:
82
+            return CGSize(width: 340, height: 364)
83
+        }
84
+    }
85
+}

+ 13 - 0
meetings_app/Widgets/WidgetsPageFactory.swift

@@ -0,0 +1,13 @@
1
+import AppKit
2
+import SwiftUI
3
+
4
+func makeWidgetsPageHost(canAddWidgets: Bool, onAddBlocked: @escaping () -> Void) -> NSView {
5
+    let host = NSHostingView(
6
+        rootView: WidgetsRootView(
7
+            canAddWidgets: canAddWidgets,
8
+            onAddBlocked: onAddBlocked
9
+        )
10
+    )
11
+    host.translatesAutoresizingMaskIntoConstraints = false
12
+    return host
13
+}

+ 147 - 0
meetings_app/Widgets/WidgetsRootView.swift

@@ -0,0 +1,147 @@
1
+import SwiftUI
2
+
3
+struct WidgetsRootView: View {
4
+    @ObservedObject private var desktopWidgetManager = DesktopWidgetWindowManager.shared
5
+    @Environment(\.colorScheme) private var colorScheme
6
+    @State private var toastMessage: String?
7
+    @State private var toastTask: Task<Void, Never>?
8
+    let canAddWidgets: Bool
9
+    let onAddBlocked: (() -> Void)?
10
+
11
+    private let variants = WidgetTemplates.meetVariants
12
+
13
+    init(canAddWidgets: Bool = true, onAddBlocked: (() -> Void)? = nil) {
14
+        self.canAddWidgets = canAddWidgets
15
+        self.onAddBlocked = onAddBlocked
16
+    }
17
+
18
+    var body: some View {
19
+        VStack(alignment: .leading, spacing: 16) {
20
+            HStack {
21
+                VStack(alignment: .leading, spacing: 3) {
22
+                    Text("Add Widget to Desktop")
23
+                        .font(.system(size: 20, weight: .bold))
24
+                    Text("Choose a size and add your Google Meet widget.")
25
+                        .font(.system(size: 13, weight: .medium))
26
+                        .foregroundStyle(secondaryTextColor)
27
+                }
28
+                Spacer()
29
+            }
30
+
31
+            GeometryReader { geo in
32
+                let spacing: CGFloat = 18
33
+                let rowWidth = variants.reduce(CGFloat(0)) { $0 + $1.previewCardSize.width } + CGFloat(max(variants.count - 1, 0)) * spacing
34
+                let scale = max(0.40, min(1, (geo.size.width - 28) / max(1, rowWidth)))
35
+                HStack(alignment: .top, spacing: spacing) {
36
+                    ForEach(variants) { variant in
37
+                        WidgetPreviewAddCard(
38
+                            variant: variant,
39
+                            layoutScale: scale,
40
+                            isAdded: desktopWidgetManager.hasInstance(variantID: variant.id),
41
+                            onToggle: { toggleWidget(variant: variant) }
42
+                        )
43
+                    }
44
+                    Spacer(minLength: 0)
45
+                }
46
+                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
47
+            }
48
+            .frame(height: variants.map(\.previewCardSize.height).max() ?? 320)
49
+
50
+            Text("Tip: Right-click a desktop widget to remove it.")
51
+                .font(.system(size: 12, weight: .medium))
52
+                .foregroundStyle(secondaryTextColor)
53
+        }
54
+        .padding(18)
55
+        .background(
56
+            RoundedRectangle(cornerRadius: 18, style: .continuous)
57
+                .fill(colorScheme == .dark ? Color.white.opacity(0.05) : Color.white.opacity(0.93))
58
+        )
59
+        .overlay(
60
+            RoundedRectangle(cornerRadius: 18, style: .continuous)
61
+                .stroke(colorScheme == .dark ? Color.white.opacity(0.09) : Color.black.opacity(0.08), lineWidth: 1)
62
+        )
63
+        .overlay(alignment: .top) {
64
+            if let toastMessage {
65
+                Text(toastMessage)
66
+                    .font(.system(size: 13, weight: .semibold))
67
+                    .foregroundStyle(colorScheme == .dark ? .white.opacity(0.95) : .primary.opacity(0.95))
68
+                    .padding(.horizontal, 14)
69
+                    .padding(.vertical, 9)
70
+                    .background(
71
+                        RoundedRectangle(cornerRadius: 12, style: .continuous)
72
+                            .fill(colorScheme == .dark ? Color.black.opacity(0.82) : Color.white.opacity(0.95))
73
+                    )
74
+                    .overlay(
75
+                        RoundedRectangle(cornerRadius: 12, style: .continuous)
76
+                            .stroke(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.08), lineWidth: 1)
77
+                    )
78
+                    .padding(.top, 8)
79
+            }
80
+        }
81
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
82
+    }
83
+
84
+    private var secondaryTextColor: Color {
85
+        colorScheme == .dark ? .white.opacity(0.72) : .primary.opacity(0.65)
86
+    }
87
+
88
+    private func toggleWidget(variant: WidgetVariant) {
89
+        if desktopWidgetManager.hasInstance(variantID: variant.id) {
90
+            let removed = desktopWidgetManager.removeInstances(variantID: variant.id)
91
+            guard removed > 0 else { return }
92
+            showToast("Removed \(variant.size.title) widget from desktop.")
93
+        } else {
94
+            guard canAddWidgets else {
95
+                showToast("Widgets are Premium only. Upgrade to add widgets.")
96
+                onAddBlocked?()
97
+                return
98
+            }
99
+            desktopWidgetManager.show(
100
+                instance: WidgetInstance(variantID: variant.id, size: variant.size, origin: nil)
101
+            )
102
+            showToast("Added \(variant.size.title) widget to desktop.")
103
+        }
104
+    }
105
+
106
+    private func showToast(_ message: String) {
107
+        toastTask?.cancel()
108
+        toastMessage = message
109
+        toastTask = Task { @MainActor in
110
+            try? await Task.sleep(nanoseconds: 1_800_000_000)
111
+            guard !Task.isCancelled else { return }
112
+            toastMessage = nil
113
+        }
114
+    }
115
+}
116
+
117
+private struct WidgetPreviewAddCard: View {
118
+    let variant: WidgetVariant
119
+    var layoutScale: CGFloat = 1
120
+    let isAdded: Bool
121
+    let onToggle: () -> Void
122
+    @State private var hovering = false
123
+
124
+    var body: some View {
125
+        ZStack(alignment: .topTrailing) {
126
+            Button(action: onToggle) {
127
+                WidgetPreviewCard(variant: variant, layoutScale: layoutScale)
128
+                    .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
129
+            }
130
+            .buttonStyle(.plain)
131
+
132
+            Button(action: onToggle) {
133
+                Image(systemName: isAdded ? "minus" : "plus")
134
+                    .font(.system(size: 12, weight: .bold))
135
+                    .foregroundStyle(.white.opacity(0.95))
136
+                    .frame(width: 26, height: 26)
137
+                    .background(Circle().fill(isAdded ? Color.red.opacity(0.92) : Color.green.opacity(0.95)))
138
+                    .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 6)
139
+            }
140
+            .buttonStyle(.plain)
141
+            .offset(x: 10 * layoutScale, y: -10 * layoutScale)
142
+            .opacity(isAdded ? 1 : (hovering ? 1 : 0))
143
+            .animation(.easeOut(duration: 0.12), value: hovering)
144
+        }
145
+        .onHover { hovering = $0 }
146
+    }
147
+}