|
@@ -6,8 +6,10 @@
|
|
|
//
|
|
//
|
|
|
|
|
|
|
|
import Cocoa
|
|
import Cocoa
|
|
|
|
|
+import Combine
|
|
|
import CryptoKit
|
|
import CryptoKit
|
|
|
import StoreKit
|
|
import StoreKit
|
|
|
|
|
+import SwiftUI
|
|
|
import WebKit
|
|
import WebKit
|
|
|
|
|
|
|
|
/// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
|
|
/// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
|
|
@@ -47,6 +49,7 @@ class ViewController: NSViewController {
|
|
|
case home = "Home"
|
|
case home = "Home"
|
|
|
case meetings = "Meetings"
|
|
case meetings = "Meetings"
|
|
|
case scheduler = "Scheduler"
|
|
case scheduler = "Scheduler"
|
|
|
|
|
+ case widgets = "Widgets"
|
|
|
case settings = "Settings"
|
|
case settings = "Settings"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -269,6 +272,7 @@ class ViewController: NSViewController {
|
|
|
private weak var schedulerMeetingsEmptyLabel: NSTextField?
|
|
private weak var schedulerMeetingsEmptyLabel: NSTextField?
|
|
|
private weak var schedulerTodayButton: NSButton?
|
|
private weak var schedulerTodayButton: NSButton?
|
|
|
private weak var meetingsPageRootView: NSView?
|
|
private weak var meetingsPageRootView: NSView?
|
|
|
|
|
+ private weak var widgetsPageRootView: NSView?
|
|
|
private weak var meetingsPageCategoryFilter: NSPopUpButton?
|
|
private weak var meetingsPageCategoryFilter: NSPopUpButton?
|
|
|
private weak var meetingsPageFromDatePicker: NSDatePicker?
|
|
private weak var meetingsPageFromDatePicker: NSDatePicker?
|
|
|
private weak var meetingsPageToDatePicker: NSDatePicker?
|
|
private weak var meetingsPageToDatePicker: NSDatePicker?
|
|
@@ -550,7 +554,7 @@ class ViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private static func isTransactionActive(_ transaction: Transaction) -> Bool {
|
|
|
|
|
|
|
+ private static func isTransactionActive(_ transaction: StoreKit.Transaction) -> Bool {
|
|
|
if transaction.revocationDate != nil { return false }
|
|
if transaction.revocationDate != nil { return false }
|
|
|
if let expirationDate = transaction.expirationDate {
|
|
if let expirationDate = transaction.expirationDate {
|
|
|
return expirationDate > Date()
|
|
return expirationDate > Date()
|
|
@@ -575,6 +579,7 @@ class ViewController: NSViewController {
|
|
|
ensureUsageSessionStartDate()
|
|
ensureUsageSessionStartDate()
|
|
|
scheduleUsageBasedRatingPromptIfNeeded()
|
|
scheduleUsageBasedRatingPromptIfNeeded()
|
|
|
setupUI()
|
|
setupUI()
|
|
|
|
|
+ setupWidgetBridge()
|
|
|
startStoreKit()
|
|
startStoreKit()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -596,6 +601,7 @@ class ViewController: NSViewController {
|
|
|
showHomeView(profile: nil)
|
|
showHomeView(profile: nil)
|
|
|
syncBrandPositionForCurrentWindowState(animated: false)
|
|
syncBrandPositionForCurrentWindowState(animated: false)
|
|
|
scheduleLaunchPaywallIfNeeded()
|
|
scheduleLaunchPaywallIfNeeded()
|
|
|
|
|
+ DesktopWidgetWindowManager.shared.restore()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
override func viewDidLayout() {
|
|
override func viewDidLayout() {
|
|
@@ -678,6 +684,7 @@ class ViewController: NSViewController {
|
|
|
meetingSearchResultsStack = nil
|
|
meetingSearchResultsStack = nil
|
|
|
meetingSearchEmptyLabel = nil
|
|
meetingSearchEmptyLabel = nil
|
|
|
meetingsPageRootView = nil
|
|
meetingsPageRootView = nil
|
|
|
|
|
+ widgetsPageRootView = nil
|
|
|
homeView?.removeFromSuperview()
|
|
homeView?.removeFromSuperview()
|
|
|
if let profileName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines), profileName.isEmpty == false {
|
|
if let profileName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines), profileName.isEmpty == false {
|
|
|
topBarProfileInitial = String(profileName.prefix(1)).uppercased()
|
|
topBarProfileInitial = String(profileName.prefix(1)).uppercased()
|
|
@@ -2391,6 +2398,7 @@ class ViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
applyFilteredMeetings()
|
|
applyFilteredMeetings()
|
|
|
applyMeetingsPageFilters(resetVisible: true)
|
|
applyMeetingsPageFilters(resetVisible: true)
|
|
|
|
|
+ updateWidgetMeetingStoreFromCurrentMeetings()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
@@ -4840,7 +4848,7 @@ class ViewController: NSViewController {
|
|
|
homeSidebarRowViews = [:]
|
|
homeSidebarRowViews = [:]
|
|
|
homeSidebarIconViews = [:]
|
|
homeSidebarIconViews = [:]
|
|
|
homeSidebarLabels = [:]
|
|
homeSidebarLabels = [:]
|
|
|
- let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler", "Settings"], selected: selectedHomeSidebarItem, style: .home)
|
|
|
|
|
|
|
+ let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler", "Widgets", "Settings"], selected: selectedHomeSidebarItem, style: .home)
|
|
|
let content = NSView()
|
|
let content = NSView()
|
|
|
content.wantsLayer = true
|
|
content.wantsLayer = true
|
|
|
content.layer?.backgroundColor = NSColor.clear.cgColor
|
|
content.layer?.backgroundColor = NSColor.clear.cgColor
|
|
@@ -5050,6 +5058,8 @@ class ViewController: NSViewController {
|
|
|
settingsView.isHidden = selectedHomeSidebarItem != "Settings"
|
|
settingsView.isHidden = selectedHomeSidebarItem != "Settings"
|
|
|
let meetingsPageView = makeMeetingsPageView()
|
|
let meetingsPageView = makeMeetingsPageView()
|
|
|
meetingsPageView.isHidden = selectedHomeSidebarItem != "Meetings"
|
|
meetingsPageView.isHidden = selectedHomeSidebarItem != "Meetings"
|
|
|
|
|
+ let widgetsPageView = makeWidgetsPageView()
|
|
|
|
|
+ widgetsPageView.isHidden = selectedHomeSidebarItem != "Widgets"
|
|
|
|
|
|
|
|
let searchOverlay = NSView()
|
|
let searchOverlay = NSView()
|
|
|
searchOverlay.wantsLayer = true
|
|
searchOverlay.wantsLayer = true
|
|
@@ -5321,7 +5331,7 @@ class ViewController: NSViewController {
|
|
|
[searchIcon, searchField, searchHintLabel].forEach {
|
|
[searchIcon, searchField, searchHintLabel].forEach {
|
|
|
searchPill.addSubview($0)
|
|
searchPill.addSubview($0)
|
|
|
}
|
|
}
|
|
|
- [timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, meetingsPageView, schedulerRoot].forEach {
|
|
|
|
|
|
|
+ [timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, meetingsPageView, widgetsPageView, schedulerRoot].forEach {
|
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
|
mainContentColumn.addSubview($0)
|
|
mainContentColumn.addSubview($0)
|
|
|
}
|
|
}
|
|
@@ -5580,6 +5590,7 @@ class ViewController: NSViewController {
|
|
|
homePlaceholderLabel = placeholder
|
|
homePlaceholderLabel = placeholder
|
|
|
homeSettingsView = settingsView
|
|
homeSettingsView = settingsView
|
|
|
meetingsPageRootView = meetingsPageView
|
|
meetingsPageRootView = meetingsPageView
|
|
|
|
|
+ widgetsPageRootView = widgetsPageView
|
|
|
schedulerRootView = schedulerRoot
|
|
schedulerRootView = schedulerRoot
|
|
|
self.schedulerMonthLabel = schedulerMonthLabel
|
|
self.schedulerMonthLabel = schedulerMonthLabel
|
|
|
schedulerCalendarGridStack = schedulerCalendarGrid
|
|
schedulerCalendarGridStack = schedulerCalendarGrid
|
|
@@ -5737,6 +5748,83 @@ class ViewController: NSViewController {
|
|
|
return button
|
|
return button
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func makeWidgetsPageView() -> NSView {
|
|
|
|
|
+ let canAdd = isUserLoggedIn() && storeKitCoordinator.hasPremiumAccess
|
|
|
|
|
+ let host = NSHostingView(
|
|
|
|
|
+ rootView: WidgetsRootView(
|
|
|
|
|
+ canAddWidgets: canAdd,
|
|
|
|
|
+ onAddBlocked: { [weak self] in
|
|
|
|
|
+ self?.showPaywall()
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ host.wantsLayer = true
|
|
|
|
|
+ host.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
|
|
+ return host
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func updateWidgetMeetingStoreFromCurrentMeetings() {
|
|
|
|
|
+ let upcoming = allScheduledMeetings
|
|
|
|
|
+ .filter { $0.start >= Date() }
|
|
|
|
|
+ .sorted(by: { $0.start < $1.start })
|
|
|
|
|
+ .prefix(3)
|
|
|
|
|
+ .map { meeting in
|
|
|
|
|
+ WidgetMeetingSnapshot(
|
|
|
|
|
+ id: meeting.meetingID ?? meeting.title,
|
|
|
|
|
+ title: meeting.title,
|
|
|
|
|
+ timeText: widgetTimeText(for: meeting),
|
|
|
|
|
+ joinLink: meeting.webURL?.absoluteString
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ WidgetMeetingStore.save(Array(upcoming))
|
|
|
|
|
+ DesktopWidgetWindowManager.shared.refreshForMeetingsChange()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func widgetTimeText(for meeting: ScheduledMeeting) -> String {
|
|
|
|
|
+ let formatter = DateFormatter()
|
|
|
|
|
+ formatter.dateFormat = "h:mm a"
|
|
|
|
|
+ formatter.timeZone = meeting.meetingTimeZone ?? TimeZone.current
|
|
|
|
|
+ return formatter.string(from: meeting.start)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func setupWidgetBridge() {
|
|
|
|
|
+ let center = NotificationCenter.default
|
|
|
|
|
+ center.addObserver(forName: .widgetOpenHomePage, object: nil, queue: .main) { [weak self] _ in
|
|
|
|
|
+ self?.menuBarShowSection(.home)
|
|
|
|
|
+ }
|
|
|
|
|
+ center.addObserver(forName: .widgetOpenMeetingsPage, object: nil, queue: .main) { [weak self] _ in
|
|
|
|
|
+ self?.menuBarShowSection(.meetings)
|
|
|
|
|
+ }
|
|
|
|
|
+ center.addObserver(forName: .widgetOpenSchedulerPage, object: nil, queue: .main) { [weak self] _ in
|
|
|
|
|
+ self?.menuBarShowSection(.scheduler)
|
|
|
|
|
+ }
|
|
|
|
|
+ center.addObserver(forName: .widgetOpenWidgetsPage, object: nil, queue: .main) { [weak self] _ in
|
|
|
|
|
+ self?.menuBarShowSection(.widgets)
|
|
|
|
|
+ }
|
|
|
|
|
+ center.addObserver(forName: .widgetOpenSettingsPage, object: nil, queue: .main) { [weak self] _ in
|
|
|
|
|
+ self?.menuBarShowSection(.settings)
|
|
|
|
|
+ }
|
|
|
|
|
+ center.addObserver(forName: .widgetStartNewMeetingRequested, object: nil, queue: .main) { [weak self] _ in
|
|
|
|
|
+ self?.menuBarTriggerNewMeeting()
|
|
|
|
|
+ }
|
|
|
|
|
+ center.addObserver(forName: .widgetJoinMeetingRequested, object: nil, queue: .main) { [weak self] _ in
|
|
|
|
|
+ self?.menuBarTriggerJoinMeeting()
|
|
|
|
|
+ }
|
|
|
|
|
+ center.addObserver(forName: .widgetScheduleMeetingRequested, object: nil, queue: .main) { [weak self] _ in
|
|
|
|
|
+ self?.menuBarTriggerScheduleMeeting()
|
|
|
|
|
+ }
|
|
|
|
|
+ center.addObserver(forName: .widgetRefreshRequested, object: nil, queue: .main) { [weak self] _ in
|
|
|
|
|
+ self?.triggerMeetingsRefresh(force: true)
|
|
|
|
|
+ }
|
|
|
|
|
+ center.addObserver(forName: .widgetOpenMeetingLinkRequested, object: nil, queue: .main) { [weak self] notification in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ guard let link = notification.userInfo?["link"] as? String,
|
|
|
|
|
+ let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
|
|
|
|
|
+ self.openWebURLPreferringInApp(url)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func makeMeetingsPageView() -> NSView {
|
|
private func makeMeetingsPageView() -> NSView {
|
|
|
let root = NSView()
|
|
let root = NSView()
|
|
|
root.wantsLayer = true
|
|
root.wantsLayer = true
|
|
@@ -6239,10 +6327,11 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func updateSelectedHomeSectionUI() {
|
|
private func updateSelectedHomeSectionUI() {
|
|
|
- let isHome = selectedHomeSidebarItem == "Home"
|
|
|
|
|
- let isMeetings = selectedHomeSidebarItem == "Meetings"
|
|
|
|
|
- let isSettings = selectedHomeSidebarItem == "Settings"
|
|
|
|
|
- let isScheduler = selectedHomeSidebarItem == "Scheduler"
|
|
|
|
|
|
|
+ let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
|
|
|
|
|
+ let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
|
|
|
|
|
+ let isSettings = selectedHomeSidebarItem == HomeSection.settings.rawValue
|
|
|
|
|
+ let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
|
|
|
|
|
+ let isWidgets = selectedHomeSidebarItem == HomeSection.widgets.rawValue
|
|
|
let premiumLocked = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
|
|
let premiumLocked = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
|
|
|
homeWelcomeLabel?.stringValue = "Home"
|
|
homeWelcomeLabel?.stringValue = "Home"
|
|
|
homeWelcomeLabel?.isHidden = (isHome == false) || isSettings || isMeetings
|
|
homeWelcomeLabel?.isHidden = (isHome == false) || isSettings || isMeetings
|
|
@@ -6260,7 +6349,7 @@ class ViewController: NSViewController {
|
|
|
meetingsScrollView,
|
|
meetingsScrollView,
|
|
|
refreshMeetingsButton
|
|
refreshMeetingsButton
|
|
|
]
|
|
]
|
|
|
- let hideDashboard = isHome == false || isSettings || isMeetings || isScheduler
|
|
|
|
|
|
|
+ let hideDashboard = isHome == false || isSettings || isMeetings || isScheduler || isWidgets
|
|
|
dashboardViews.forEach { $0?.isHidden = hideDashboard }
|
|
dashboardViews.forEach { $0?.isHidden = hideDashboard }
|
|
|
// Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
|
|
// Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
|
|
|
// and showed "No meetings…" on top of meeting cards when returning to Home.
|
|
// and showed "No meetings…" on top of meeting cards when returning to Home.
|
|
@@ -6273,6 +6362,7 @@ class ViewController: NSViewController {
|
|
|
homeSettingsView?.isHidden = isSettings == false
|
|
homeSettingsView?.isHidden = isSettings == false
|
|
|
meetingsPageRootView?.isHidden = isMeetings == false
|
|
meetingsPageRootView?.isHidden = isMeetings == false
|
|
|
schedulerRootView?.isHidden = isScheduler == false
|
|
schedulerRootView?.isHidden = isScheduler == false
|
|
|
|
|
+ widgetsPageRootView?.isHidden = isWidgets == false
|
|
|
|
|
|
|
|
if isHome {
|
|
if isHome {
|
|
|
homePlaceholderLabel?.isHidden = true
|
|
homePlaceholderLabel?.isHidden = true
|
|
@@ -6302,6 +6392,8 @@ class ViewController: NSViewController {
|
|
|
case "Scheduler":
|
|
case "Scheduler":
|
|
|
// `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
|
|
// `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
|
|
|
return "calendar.badge.clock"
|
|
return "calendar.badge.clock"
|
|
|
|
|
+ case "Widgets":
|
|
|
|
|
+ return filled ? "rectangle.grid.2x2.fill" : "rectangle.grid.2x2"
|
|
|
case "Settings":
|
|
case "Settings":
|
|
|
// `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
|
|
// `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
|
|
|
return filled ? "gearshape.fill" : "gearshape"
|
|
return filled ? "gearshape.fill" : "gearshape"
|
|
@@ -7564,3 +7656,827 @@ extension ZoomOAuthError: LocalizedError {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// MARK: - Desktop Widgets (Zoom)
|
|
|
|
|
+
|
|
|
|
|
+private struct WidgetQuickAction: Identifiable, Hashable {
|
|
|
|
|
+ enum Destination: String, Hashable {
|
|
|
|
|
+ case home
|
|
|
|
|
+ case meetings
|
|
|
|
|
+ case scheduler
|
|
|
|
|
+ case widgets
|
|
|
|
|
+ case settings
|
|
|
|
|
+ case newMeeting
|
|
|
|
|
+ case joinMeeting
|
|
|
|
|
+ case scheduleMeeting
|
|
|
|
|
+ case refreshMeetings
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let id: String
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let systemImage: String
|
|
|
|
|
+ let destination: Destination
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct WidgetVariant: Identifiable, Hashable {
|
|
|
|
|
+ let id: String
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let subtitle: String
|
|
|
|
|
+ let size: WidgetSize
|
|
|
|
|
+ let quickActions: [WidgetQuickAction]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private enum WidgetTemplates {
|
|
|
|
|
+ static let zoomVariants: [WidgetVariant] = [
|
|
|
|
|
+ WidgetVariant(
|
|
|
|
|
+ id: "zoom_small",
|
|
|
|
|
+ title: "Zoom",
|
|
|
|
|
+ subtitle: "Quick actions",
|
|
|
|
|
+ size: .small,
|
|
|
|
|
+ quickActions: [
|
|
|
|
|
+ WidgetQuickAction(id: "new", title: "New meeting", systemImage: "video.fill", destination: .newMeeting),
|
|
|
|
|
+ WidgetQuickAction(id: "join", title: "Join", systemImage: "plus", destination: .joinMeeting),
|
|
|
|
|
+ WidgetQuickAction(id: "schedule", title: "Schedule", systemImage: "calendar", destination: .scheduleMeeting),
|
|
|
|
|
+ WidgetQuickAction(id: "open", title: "Meetings", systemImage: "list.bullet.rectangle", destination: .meetings)
|
|
|
|
|
+ ]
|
|
|
|
|
+ ),
|
|
|
|
|
+ WidgetVariant(
|
|
|
|
|
+ id: "zoom_medium",
|
|
|
|
|
+ title: "Zoom",
|
|
|
|
|
+ subtitle: "Upcoming meetings",
|
|
|
|
|
+ size: .medium,
|
|
|
|
|
+ quickActions: [
|
|
|
|
|
+ WidgetQuickAction(id: "meetings", title: "Meetings", systemImage: "video", destination: .meetings),
|
|
|
|
|
+ WidgetQuickAction(id: "scheduler", title: "Scheduler", systemImage: "calendar.badge.clock", destination: .scheduler),
|
|
|
|
|
+ WidgetQuickAction(id: "widgets", title: "Widgets", systemImage: "rectangle.grid.2x2", destination: .widgets),
|
|
|
|
|
+ WidgetQuickAction(id: "refresh", title: "Refresh", systemImage: "arrow.clockwise", destination: .refreshMeetings)
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ static func variant(for variantID: String) -> WidgetVariant {
|
|
|
|
|
+ zoomVariants.first(where: { $0.id == variantID }) ?? zoomVariants[0]
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private extension WidgetVariant {
|
|
|
|
|
+ var previewCardSize: CGSize {
|
|
|
|
|
+ switch size {
|
|
|
|
|
+ case .small:
|
|
|
|
|
+ return CGSize(width: 196, height: 196)
|
|
|
|
|
+ case .medium:
|
|
|
|
|
+ return CGSize(width: 340, height: 288)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private enum WidgetSize: String, CaseIterable, Codable, Hashable {
|
|
|
|
|
+ case small
|
|
|
|
|
+ case medium
|
|
|
|
|
+
|
|
|
|
|
+ var title: String {
|
|
|
|
|
+ switch self {
|
|
|
|
|
+ case .small: return "Small"
|
|
|
|
|
+ case .medium: return "Medium"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct WidgetInstance: Identifiable, Codable, Hashable {
|
|
|
|
|
+ struct Origin: Codable, Hashable {
|
|
|
|
|
+ var x: Double
|
|
|
|
|
+ var y: Double
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let id: UUID
|
|
|
|
|
+ let variantID: String
|
|
|
|
|
+ var size: WidgetSize
|
|
|
|
|
+ var origin: Origin?
|
|
|
|
|
+
|
|
|
|
|
+ init(id: UUID = UUID(), variantID: String, size: WidgetSize, origin: Origin?) {
|
|
|
|
|
+ self.id = id
|
|
|
|
|
+ self.variantID = variantID
|
|
|
|
|
+ self.size = size
|
|
|
|
|
+ self.origin = origin
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct WidgetMeetingSnapshot: Codable, Identifiable {
|
|
|
|
|
+ let id: String
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let timeText: String
|
|
|
|
|
+ let joinLink: String?
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private enum WidgetMeetingStore {
|
|
|
|
|
+ static let key = "zoom_app.widget.topMeetings"
|
|
|
|
|
+
|
|
|
|
|
+ static func load() -> [WidgetMeetingSnapshot] {
|
|
|
|
|
+ guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
|
|
|
|
|
+ return (try? JSONDecoder().decode([WidgetMeetingSnapshot].self, from: data)) ?? []
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ static func save(_ meetings: [WidgetMeetingSnapshot]) {
|
|
|
|
|
+ guard let data = try? JSONEncoder().encode(meetings) else { return }
|
|
|
|
|
+ UserDefaults.standard.set(data, forKey: key)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private enum WidgetAppNavigator {
|
|
|
|
|
+ static func open(target: WidgetQuickAction.Destination) {
|
|
|
|
|
+ DispatchQueue.main.async {
|
|
|
|
|
+ bringAppToFront()
|
|
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
|
|
|
|
|
+ NotificationCenter.default.post(name: notificationName(for: target), object: nil)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ static func openMeetingLink(_ link: String) {
|
|
|
|
|
+ DispatchQueue.main.async {
|
|
|
|
|
+ bringAppToFront()
|
|
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
|
|
|
|
|
+ NotificationCenter.default.post(
|
|
|
|
|
+ name: .widgetOpenMeetingLinkRequested,
|
|
|
|
|
+ object: nil,
|
|
|
|
|
+ userInfo: ["link": link]
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func notificationName(for target: WidgetQuickAction.Destination) -> Notification.Name {
|
|
|
|
|
+ switch target {
|
|
|
|
|
+ case .home: return .widgetOpenHomePage
|
|
|
|
|
+ case .meetings: return .widgetOpenMeetingsPage
|
|
|
|
|
+ case .scheduler: return .widgetOpenSchedulerPage
|
|
|
|
|
+ case .widgets: return .widgetOpenWidgetsPage
|
|
|
|
|
+ case .settings: return .widgetOpenSettingsPage
|
|
|
|
|
+ case .newMeeting: return .widgetStartNewMeetingRequested
|
|
|
|
|
+ case .joinMeeting: return .widgetJoinMeetingRequested
|
|
|
|
|
+ case .scheduleMeeting: return .widgetScheduleMeetingRequested
|
|
|
|
|
+ case .refreshMeetings: return .widgetRefreshRequested
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func bringAppToFront() {
|
|
|
|
|
+ NSApp.unhide(nil)
|
|
|
|
|
+ NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ NSApp.arrangeInFront(nil)
|
|
|
|
|
+ for window in NSApp.windows where window.contentViewController is ViewController {
|
|
|
|
|
+ if window.isMiniaturized {
|
|
|
|
|
+ window.deminiaturize(nil)
|
|
|
|
|
+ }
|
|
|
|
|
+ window.orderFrontRegardless()
|
|
|
|
|
+ window.makeKeyAndOrderFront(nil)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private extension Notification.Name {
|
|
|
|
|
+ static let widgetOpenHomePage = Notification.Name("widgetOpenHomePage")
|
|
|
|
|
+ static let widgetOpenMeetingsPage = Notification.Name("widgetOpenMeetingsPage")
|
|
|
|
|
+ static let widgetOpenSchedulerPage = Notification.Name("widgetOpenSchedulerPage")
|
|
|
|
|
+ static let widgetOpenWidgetsPage = Notification.Name("widgetOpenWidgetsPage")
|
|
|
|
|
+ static let widgetOpenSettingsPage = Notification.Name("widgetOpenSettingsPage")
|
|
|
|
|
+ static let widgetStartNewMeetingRequested = Notification.Name("widgetStartNewMeetingRequested")
|
|
|
|
|
+ static let widgetJoinMeetingRequested = Notification.Name("widgetJoinMeetingRequested")
|
|
|
|
|
+ static let widgetScheduleMeetingRequested = Notification.Name("widgetScheduleMeetingRequested")
|
|
|
|
|
+ static let widgetRefreshRequested = Notification.Name("widgetRefreshRequested")
|
|
|
|
|
+ static let widgetOpenMeetingLinkRequested = Notification.Name("widgetOpenMeetingLinkRequested")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private final class DesktopWidgetWindowManager: ObservableObject {
|
|
|
|
|
+ static let shared = DesktopWidgetWindowManager()
|
|
|
|
|
+
|
|
|
|
|
+ private let storageKey = "zoom_app.widget.instancesData"
|
|
|
|
|
+ private var windowsByInstanceID: [UUID: NSPanel] = [:]
|
|
|
|
|
+ @Published private(set) var instancesChangeToken: Int = 0
|
|
|
|
|
+ @Published private(set) var meetingsRefreshToken: Int = 0
|
|
|
|
|
+
|
|
|
|
|
+ private init() {}
|
|
|
|
|
+
|
|
|
|
|
+ func hasInstance(variantID: String) -> Bool {
|
|
|
|
|
+ loadInstances().contains { $0.variantID == variantID }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func show(instance: WidgetInstance) {
|
|
|
|
|
+ let variant = WidgetTemplates.variant(for: instance.variantID)
|
|
|
|
|
+ var resolved = instance
|
|
|
|
|
+ if resolved.origin == nil {
|
|
|
|
|
+ resolved.origin = nextAutoPlacement(for: variant)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let panel = makePanel(for: resolved, variant: variant)
|
|
|
|
|
+ windowsByInstanceID[resolved.id] = panel
|
|
|
|
|
+ persistUpsert(instance: resolved)
|
|
|
|
|
+ if let origin = resolved.origin {
|
|
|
|
|
+ panel.setFrameOrigin(NSPoint(x: origin.x, y: origin.y))
|
|
|
|
|
+ persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ panel.center()
|
|
|
|
|
+ persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
|
|
|
|
|
+ }
|
|
|
|
|
+ panel.orderFront(nil)
|
|
|
|
|
+ panel.orderFrontRegardless()
|
|
|
|
|
+ instancesChangeToken += 1
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @discardableResult
|
|
|
|
|
+ func removeInstances(variantID: String) -> Int {
|
|
|
|
|
+ let instances = loadInstances()
|
|
|
|
|
+ let removed = instances.filter { $0.variantID == variantID }
|
|
|
|
|
+ let remaining = instances.filter { $0.variantID != variantID }
|
|
|
|
|
+ for instance in removed {
|
|
|
|
|
+ if let panel = windowsByInstanceID.removeValue(forKey: instance.id) {
|
|
|
|
|
+ panel.close()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ saveInstances(remaining)
|
|
|
|
|
+ instancesChangeToken += 1
|
|
|
|
|
+ return removed.count
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func restore() {
|
|
|
|
|
+ for instance in loadInstances() where windowsByInstanceID[instance.id] == nil {
|
|
|
|
|
+ show(instance: instance)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshForMeetingsChange() {
|
|
|
|
|
+ resizeVisiblePanelsForCurrentContent()
|
|
|
|
|
+ meetingsRefreshToken += 1
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func makePanel(for instance: WidgetInstance, variant: WidgetVariant) -> NSPanel {
|
|
|
|
|
+ let size = panelSize(for: variant.size)
|
|
|
|
|
+ let panel = DesktopWidgetPanel(
|
|
|
|
|
+ contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
|
|
|
|
|
+ styleMask: [.nonactivatingPanel, .borderless],
|
|
|
|
|
+ backing: .buffered,
|
|
|
|
|
+ defer: false
|
|
|
|
|
+ )
|
|
|
|
|
+ panel.becomesKeyOnlyIfNeeded = false
|
|
|
|
|
+ panel.isReleasedWhenClosed = false
|
|
|
|
|
+ panel.isFloatingPanel = false
|
|
|
|
|
+ panel.isMovable = true
|
|
|
|
|
+ panel.hasShadow = false
|
|
|
|
|
+ panel.isOpaque = false
|
|
|
|
|
+ panel.backgroundColor = .clear
|
|
|
|
|
+ panel.isMovableByWindowBackground = true
|
|
|
|
|
+ panel.hidesOnDeactivate = false
|
|
|
|
|
+ panel.ignoresMouseEvents = false
|
|
|
|
|
+ let desktopIconLevel = Int(CGWindowLevelForKey(.desktopIconWindow))
|
|
|
|
|
+ panel.level = NSWindow.Level(rawValue: desktopIconLevel + 1)
|
|
|
|
|
+ panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
|
|
|
|
|
+
|
|
|
|
|
+ let host = NSHostingView(rootView: DesktopWidgetHostView(instanceID: instance.id, variant: variant))
|
|
|
|
|
+ host.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ let container = NSView()
|
|
|
|
|
+ container.wantsLayer = true
|
|
|
|
|
+ container.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
|
|
+ container.layer?.cornerRadius = 22
|
|
|
|
|
+ container.layer?.masksToBounds = true
|
|
|
|
|
+ container.addSubview(host)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ host.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
|
|
|
+ host.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
|
|
|
+ host.topAnchor.constraint(equalTo: container.topAnchor),
|
|
|
|
|
+ host.bottomAnchor.constraint(equalTo: container.bottomAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+ panel.contentView = container
|
|
|
|
|
+
|
|
|
|
|
+ NotificationCenter.default.addObserver(
|
|
|
|
|
+ forName: NSWindow.didMoveNotification,
|
|
|
|
|
+ object: panel,
|
|
|
|
|
+ queue: .main
|
|
|
|
|
+ ) { [weak self, weak panel] _ in
|
|
|
|
|
+ guard let self, let panel else { return }
|
|
|
|
|
+ self.persistOrigin(instanceID: instance.id, origin: panel.frame.origin)
|
|
|
|
|
+ }
|
|
|
|
|
+ return panel
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func panelSize(for size: WidgetSize) -> CGSize {
|
|
|
|
|
+ switch size {
|
|
|
|
|
+ case .small:
|
|
|
|
|
+ return CGSize(width: 220, height: 204)
|
|
|
|
|
+ case .medium:
|
|
|
|
|
+ return CGSize(width: 390, height: mediumPanelHeight())
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func mediumPanelHeight() -> CGFloat {
|
|
|
|
|
+ let visibleMeetingCount = min(WidgetMeetingStore.load().count, 2)
|
|
|
|
|
+ switch visibleMeetingCount {
|
|
|
|
|
+ case 0:
|
|
|
|
|
+ return 252
|
|
|
|
|
+ case 1:
|
|
|
|
|
+ return 270
|
|
|
|
|
+ default:
|
|
|
|
|
+ return 288
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func resizeVisiblePanelsForCurrentContent() {
|
|
|
|
|
+ let instancesByID = Dictionary(uniqueKeysWithValues: loadInstances().map { ($0.id, $0) })
|
|
|
|
|
+ for (instanceID, panel) in windowsByInstanceID {
|
|
|
|
|
+ guard let instance = instancesByID[instanceID] else { continue }
|
|
|
|
|
+ let variant = WidgetTemplates.variant(for: instance.variantID)
|
|
|
|
|
+ let targetSize = panelSize(for: variant.size)
|
|
|
|
|
+ let currentSize = panel.frame.size
|
|
|
|
|
+ guard abs(currentSize.height - targetSize.height) > 0.5 || abs(currentSize.width - targetSize.width) > 0.5 else {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var frame = panel.frame
|
|
|
|
|
+ let oldMaxY = frame.maxY
|
|
|
|
|
+ frame.size = targetSize
|
|
|
|
|
+ frame.origin.y = oldMaxY - targetSize.height
|
|
|
|
|
+ panel.setFrame(frame, display: true)
|
|
|
|
|
+ persistOrigin(instanceID: instanceID, origin: frame.origin)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func nextAutoPlacement(for variant: WidgetVariant) -> WidgetInstance.Origin {
|
|
|
|
|
+ let size = panelSize(for: variant.size)
|
|
|
|
|
+ let frame = NSScreen.main?.visibleFrame ?? NSRect(x: 100, y: 100, width: 1200, height: 800)
|
|
|
|
|
+ let margin: CGFloat = 18
|
|
|
|
|
+ let gapX: CGFloat = 22
|
|
|
|
|
+ let gapY: CGFloat = 18
|
|
|
|
|
+ let cellW = size.width + gapX
|
|
|
|
|
+ let cellH = size.height + gapY
|
|
|
|
|
+ let existingRects = loadInstances().compactMap { instance -> NSRect? in
|
|
|
|
|
+ guard let origin = instance.origin else { return nil }
|
|
|
|
|
+ let instanceSize = panelSize(for: instance.size)
|
|
|
|
|
+ return NSRect(x: origin.x, y: origin.y, width: instanceSize.width, height: instanceSize.height)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let cols = max(1, Int((frame.width - margin * 2) / max(cellW, 1)))
|
|
|
|
|
+ let rows = max(1, Int((frame.height - margin * 2) / max(cellH, 1)))
|
|
|
|
|
+ let maxSlots = max(120, cols * rows)
|
|
|
|
|
+ for slot in 0..<maxSlots {
|
|
|
|
|
+ let row = slot / cols
|
|
|
|
|
+ let col = cols - 1 - (slot % cols)
|
|
|
|
|
+ let x = frame.minX + margin + CGFloat(col) * cellW
|
|
|
|
|
+ let y = frame.maxY - margin - size.height - CGFloat(row) * cellH
|
|
|
|
|
+ let candidate = NSRect(x: x, y: y, width: size.width, height: size.height)
|
|
|
|
|
+ guard candidate.minX >= frame.minX + margin,
|
|
|
|
|
+ candidate.maxX <= frame.maxX - margin,
|
|
|
|
|
+ candidate.minY >= frame.minY + margin else {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if !existingRects.contains(where: { $0.intersects(candidate.insetBy(dx: -8, dy: -8)) }) {
|
|
|
|
|
+ return WidgetInstance.Origin(x: candidate.minX, y: candidate.minY)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return WidgetInstance.Origin(
|
|
|
|
|
+ x: frame.midX - size.width / 2,
|
|
|
|
|
+ y: frame.midY - size.height / 2
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func close(instanceID: UUID) {
|
|
|
|
|
+ if let panel = windowsByInstanceID.removeValue(forKey: instanceID) {
|
|
|
|
|
+ panel.close()
|
|
|
|
|
+ }
|
|
|
|
|
+ let remaining = loadInstances().filter { $0.id != instanceID }
|
|
|
|
|
+ saveInstances(remaining)
|
|
|
|
|
+ instancesChangeToken += 1
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func loadInstances() -> [WidgetInstance] {
|
|
|
|
|
+ guard let data = UserDefaults.standard.data(forKey: storageKey) else { return [] }
|
|
|
|
|
+ let decoded = (try? JSONDecoder().decode([WidgetInstance].self, from: data)) ?? []
|
|
|
|
|
+ let allowedByVariantID = Dictionary(uniqueKeysWithValues: WidgetTemplates.zoomVariants.map { ($0.id, $0.size) })
|
|
|
|
|
+ let sanitized = decoded.compactMap { instance -> WidgetInstance? in
|
|
|
|
|
+ guard let expectedSize = allowedByVariantID[instance.variantID] else { return nil }
|
|
|
|
|
+ var normalized = instance
|
|
|
|
|
+ normalized.size = expectedSize
|
|
|
|
|
+ return normalized
|
|
|
|
|
+ }
|
|
|
|
|
+ if sanitized != decoded {
|
|
|
|
|
+ saveInstances(sanitized)
|
|
|
|
|
+ }
|
|
|
|
|
+ return sanitized
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func saveInstances(_ instances: [WidgetInstance]) {
|
|
|
|
|
+ guard let data = try? JSONEncoder().encode(instances) else { return }
|
|
|
|
|
+ UserDefaults.standard.set(data, forKey: storageKey)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func persistUpsert(instance: WidgetInstance) {
|
|
|
|
|
+ var instances = loadInstances()
|
|
|
|
|
+ if let idx = instances.firstIndex(where: { $0.id == instance.id }) {
|
|
|
|
|
+ instances[idx] = instance
|
|
|
|
|
+ } else {
|
|
|
|
|
+ instances.append(instance)
|
|
|
|
|
+ }
|
|
|
|
|
+ saveInstances(instances)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func persistOrigin(instanceID: UUID, origin: NSPoint) {
|
|
|
|
|
+ var instances = loadInstances()
|
|
|
|
|
+ guard let idx = instances.firstIndex(where: { $0.id == instanceID }) else { return }
|
|
|
|
|
+ instances[idx].origin = WidgetInstance.Origin(x: origin.x, y: origin.y)
|
|
|
|
|
+ saveInstances(instances)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private struct DesktopWidgetHostView: View {
|
|
|
|
|
+ let instanceID: UUID
|
|
|
|
|
+ let variant: WidgetVariant
|
|
|
|
|
+ @ObservedObject private var manager = DesktopWidgetWindowManager.shared
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ DesktopWidgetView(variant: variant, isPreview: false)
|
|
|
|
|
+ .id(manager.meetingsRefreshToken)
|
|
|
|
|
+ .contextMenu {
|
|
|
|
|
+ Button("Remove Widget", role: .destructive) {
|
|
|
|
|
+ DesktopWidgetWindowManager.shared.close(instanceID: instanceID)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private final class DesktopWidgetPanel: NSPanel {
|
|
|
|
|
+ override var canBecomeKey: Bool { true }
|
|
|
|
|
+ override var canBecomeMain: Bool { false }
|
|
|
|
|
+
|
|
|
|
|
+ override func mouseDown(with event: NSEvent) {
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ makeKeyAndOrderFront(nil)
|
|
|
|
|
+ super.mouseDown(with: event)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct WidgetsRootView: View {
|
|
|
|
|
+ @ObservedObject private var desktopWidgetManager = DesktopWidgetWindowManager.shared
|
|
|
|
|
+ @Environment(\.colorScheme) private var colorScheme
|
|
|
|
|
+ @State private var toastMessage: String?
|
|
|
|
|
+ @State private var toastTask: Task<Void, Never>?
|
|
|
|
|
+ let canAddWidgets: Bool
|
|
|
|
|
+ let onAddBlocked: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private let variants = WidgetTemplates.zoomVariants
|
|
|
|
|
+
|
|
|
|
|
+ init(canAddWidgets: Bool = true, onAddBlocked: (() -> Void)? = nil) {
|
|
|
|
|
+ self.canAddWidgets = canAddWidgets
|
|
|
|
|
+ self.onAddBlocked = onAddBlocked
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 16) {
|
|
|
|
|
+ HStack {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 3) {
|
|
|
|
|
+ Text("Add Widget to Desktop")
|
|
|
|
|
+ .font(.system(size: 20, weight: .bold))
|
|
|
|
|
+ Text("Choose a size and add your Zoom desktop widget.")
|
|
|
|
|
+ .font(.system(size: 13, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(secondaryTextColor)
|
|
|
|
|
+ }
|
|
|
|
|
+ Spacer()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ GeometryReader { geo in
|
|
|
|
|
+ let spacing: CGFloat = 18
|
|
|
|
|
+ let rowWidth = variants.reduce(CGFloat(0)) { $0 + $1.previewCardSize.width } + CGFloat(max(variants.count - 1, 0)) * spacing
|
|
|
|
|
+ let scale = max(0.40, min(1, (geo.size.width - 28) / max(1, rowWidth)))
|
|
|
|
|
+ HStack(alignment: .top, spacing: spacing) {
|
|
|
|
|
+ ForEach(variants) { variant in
|
|
|
|
|
+ WidgetPreviewAddCard(
|
|
|
|
|
+ variant: variant,
|
|
|
|
|
+ layoutScale: scale,
|
|
|
|
|
+ isAdded: desktopWidgetManager.hasInstance(variantID: variant.id),
|
|
|
|
|
+ onToggle: { toggleWidget(variant: variant) }
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(height: variants.map(\.previewCardSize.height).max() ?? 320)
|
|
|
|
|
+
|
|
|
|
|
+ Text("Tip: Right-click a desktop widget to remove it.")
|
|
|
|
|
+ .font(.system(size: 12, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(secondaryTextColor)
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(18)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
|
|
|
+ .fill(colorScheme == .dark ? Color.white.opacity(0.05) : Color.white.opacity(0.93))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
|
|
|
+ .stroke(colorScheme == .dark ? Color.white.opacity(0.09) : Color.black.opacity(0.08), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(alignment: .top) {
|
|
|
|
|
+ if let toastMessage {
|
|
|
|
|
+ Text(toastMessage)
|
|
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(colorScheme == .dark ? .white.opacity(0.95) : .primary.opacity(0.95))
|
|
|
|
|
+ .padding(.horizontal, 14)
|
|
|
|
|
+ .padding(.vertical, 9)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
+ .fill(colorScheme == .dark ? Color.black.opacity(0.82) : Color.white.opacity(0.95))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
+ .stroke(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.08), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ .padding(.top, 8)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var secondaryTextColor: Color {
|
|
|
|
|
+ colorScheme == .dark ? .white.opacity(0.72) : .primary.opacity(0.65)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func toggleWidget(variant: WidgetVariant) {
|
|
|
|
|
+ if desktopWidgetManager.hasInstance(variantID: variant.id) {
|
|
|
|
|
+ let removed = desktopWidgetManager.removeInstances(variantID: variant.id)
|
|
|
|
|
+ guard removed > 0 else { return }
|
|
|
|
|
+ showToast("Removed \(variant.size.title) widget from desktop.")
|
|
|
|
|
+ } else {
|
|
|
|
|
+ guard canAddWidgets else {
|
|
|
|
|
+ showToast("Widgets are Premium only. Upgrade to add widgets.")
|
|
|
|
|
+ onAddBlocked?()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ desktopWidgetManager.show(
|
|
|
|
|
+ instance: WidgetInstance(variantID: variant.id, size: variant.size, origin: nil)
|
|
|
|
|
+ )
|
|
|
|
|
+ showToast("Added \(variant.size.title) widget to desktop.")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func showToast(_ message: String) {
|
|
|
|
|
+ toastTask?.cancel()
|
|
|
|
|
+ toastMessage = message
|
|
|
|
|
+ toastTask = Task { @MainActor in
|
|
|
|
|
+ try? await Task.sleep(nanoseconds: 1_800_000_000)
|
|
|
|
|
+ guard !Task.isCancelled else { return }
|
|
|
|
|
+ toastMessage = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct WidgetPreviewAddCard: View {
|
|
|
|
|
+ let variant: WidgetVariant
|
|
|
|
|
+ var layoutScale: CGFloat = 1
|
|
|
|
|
+ let isAdded: Bool
|
|
|
|
|
+ let onToggle: () -> Void
|
|
|
|
|
+ @State private var hovering = false
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ ZStack(alignment: .topTrailing) {
|
|
|
|
|
+ Button(action: onToggle) {
|
|
|
|
|
+ WidgetPreviewCard(variant: variant, layoutScale: layoutScale)
|
|
|
|
|
+ .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+
|
|
|
|
|
+ Button(action: onToggle) {
|
|
|
|
|
+ Image(systemName: isAdded ? "minus" : "plus")
|
|
|
|
|
+ .font(.system(size: 12, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.95))
|
|
|
|
|
+ .frame(width: 26, height: 26)
|
|
|
|
|
+ .background(Circle().fill(isAdded ? Color.red.opacity(0.92) : Color.green.opacity(0.95)))
|
|
|
|
|
+ .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 6)
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .offset(x: 10 * layoutScale, y: -10 * layoutScale)
|
|
|
|
|
+ .opacity(isAdded ? 1 : (hovering ? 1 : 0))
|
|
|
|
|
+ .animation(.easeOut(duration: 0.12), value: hovering)
|
|
|
|
|
+ }
|
|
|
|
|
+ .onHover { hovering = $0 }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct WidgetPreviewCard: View {
|
|
|
|
|
+ let variant: WidgetVariant
|
|
|
|
|
+ var layoutScale: CGFloat = 1
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ let size = variant.previewCardSize
|
|
|
|
|
+ let scale = max(0.01, layoutScale)
|
|
|
|
|
+ DesktopWidgetView(variant: variant, isPreview: true)
|
|
|
|
|
+ .frame(width: size.width, height: size.height)
|
|
|
|
|
+ .scaleEffect(scale)
|
|
|
|
|
+ .frame(width: size.width * scale, height: size.height * scale)
|
|
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
|
|
|
+ .stroke(Color.white.opacity(0.12), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct DesktopWidgetView: View {
|
|
|
|
|
+ let variant: WidgetVariant
|
|
|
|
|
+ var isPreview: Bool
|
|
|
|
|
+
|
|
|
|
|
+ private var topMeetings: [WidgetMeetingSnapshot] {
|
|
|
|
|
+ WidgetMeetingStore.load().prefix(3).map { $0 }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ ZStack {
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
|
|
|
+ .fill(
|
|
|
|
|
+ LinearGradient(
|
|
|
|
|
+ colors: [
|
|
|
|
|
+ Color(red: 0.11, green: 0.45, blue: 0.98),
|
|
|
|
|
+ Color(red: 0.14, green: 0.72, blue: 0.98)
|
|
|
|
|
+ ],
|
|
|
|
|
+ startPoint: .topLeading,
|
|
|
|
|
+ endPoint: .bottomTrailing
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
|
|
|
+ .stroke(Color.white.opacity(0.14), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 12) {
|
|
|
|
|
+ widgetHeader
|
|
|
|
|
+ contentBody
|
|
|
|
|
+ if variant.size != .small {
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.horizontal, 14)
|
|
|
|
|
+ .padding(.bottom, variant.size == .medium ? 16 : 14)
|
|
|
|
|
+ .padding(.top, variant.size == .medium ? 18 : 10)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @ViewBuilder
|
|
|
|
|
+ private var contentBody: some View {
|
|
|
|
|
+ switch variant.size {
|
|
|
|
|
+ case .small:
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
+ Text("Quick actions")
|
|
|
|
|
+ .font(.system(size: 11.5, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.78))
|
|
|
|
|
+ compactActionButton(title: "New meeting", icon: "video.fill", destination: .newMeeting)
|
|
|
|
|
+ HStack(spacing: 8) {
|
|
|
|
|
+ compactActionButton(title: "Join", icon: "plus", destination: .joinMeeting)
|
|
|
|
|
+ compactActionButton(title: "Schedule", icon: "calendar", destination: .scheduleMeeting)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ case .medium:
|
|
|
|
|
+ meetingBlock(maxRows: 2)
|
|
|
|
|
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 0), spacing: 8), count: 2), spacing: 8) {
|
|
|
|
|
+ ForEach(variant.quickActions.prefix(4)) { action in
|
|
|
|
|
+ actionTile(action)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var widgetHeader: some View {
|
|
|
|
|
+ HStack(spacing: 10) {
|
|
|
|
|
+ Image(systemName: "video.fill")
|
|
|
|
|
+ .font(.system(size: 16, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.92))
|
|
|
|
|
+ .frame(width: 30, height: 30)
|
|
|
|
|
+ .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.black.opacity(0.18)))
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 2) {
|
|
|
|
|
+ Text(variant.title)
|
|
|
|
|
+ .font(.system(size: 16, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.95))
|
|
|
|
|
+ Text(variant.subtitle)
|
|
|
|
|
+ .font(.system(size: 12, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.8))
|
|
|
|
|
+ }
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func meetingBlock(maxRows: Int) -> some View {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 9) {
|
|
|
|
|
+ Text("Upcoming meetings")
|
|
|
|
|
+ .font(.system(size: 11.5, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.80))
|
|
|
|
|
+ if topMeetings.isEmpty {
|
|
|
|
|
+ Text("No upcoming meetings")
|
|
|
|
|
+ .font(.system(size: 12, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.82))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 8) {
|
|
|
|
|
+ ForEach(topMeetings.prefix(maxRows)) { meeting in
|
|
|
|
|
+ meetingRow(meeting)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func meetingRow(_ meeting: WidgetMeetingSnapshot) -> some View {
|
|
|
|
|
+ Button(action: {
|
|
|
|
|
+ if let link = meeting.joinLink?.trimmingCharacters(in: .whitespacesAndNewlines), !link.isEmpty {
|
|
|
|
|
+ WidgetAppNavigator.openMeetingLink(link)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ WidgetAppNavigator.open(target: .meetings)
|
|
|
|
|
+ }
|
|
|
|
|
+ }) {
|
|
|
|
|
+ HStack(spacing: 8) {
|
|
|
|
|
+ Image(systemName: "video.fill")
|
|
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.88))
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 1) {
|
|
|
|
|
+ Text(meeting.title)
|
|
|
|
|
+ .font(.system(size: 11.5, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.93))
|
|
|
|
|
+ .lineLimit(1)
|
|
|
|
|
+ Text(meeting.timeText)
|
|
|
|
|
+ .font(.system(size: 10.5, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.74))
|
|
|
|
|
+ .lineLimit(1)
|
|
|
|
|
+ }
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(.horizontal, 10)
|
|
|
|
|
+ .padding(.vertical, 7)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
|
|
+ .fill(Color.black.opacity(0.20))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
|
|
+ .stroke(Color.white.opacity(0.08), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .disabled(isPreview)
|
|
|
|
|
+ .allowsHitTesting(!isPreview)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func compactActionButton(title: String, icon: String, destination: WidgetQuickAction.Destination) -> some View {
|
|
|
|
|
+ Button(action: { WidgetAppNavigator.open(target: destination) }) {
|
|
|
|
|
+ HStack(spacing: 5) {
|
|
|
|
|
+ Image(systemName: icon)
|
|
|
|
|
+ .font(.system(size: 10.5, weight: .semibold))
|
|
|
|
|
+ .frame(width: 14)
|
|
|
|
|
+ Text(title)
|
|
|
|
|
+ .font(.system(size: 11.5, weight: .bold))
|
|
|
|
|
+ .lineLimit(1)
|
|
|
|
|
+ .minimumScaleFactor(0.82)
|
|
|
|
|
+ .layoutPriority(1)
|
|
|
|
|
+ }
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.97))
|
|
|
|
|
+ .padding(.horizontal, 10)
|
|
|
|
|
+ .padding(.vertical, 8)
|
|
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
+ .fill(Color.black.opacity(0.30))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
+ .stroke(Color.white.opacity(0.24), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .disabled(isPreview)
|
|
|
|
|
+ .allowsHitTesting(!isPreview)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func actionTile(_ action: WidgetQuickAction) -> some View {
|
|
|
|
|
+ Button(action: { open(action: action) }) {
|
|
|
|
|
+ HStack(spacing: 8) {
|
|
|
|
|
+ Image(systemName: action.systemImage)
|
|
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
|
|
+ Text(action.title)
|
|
|
|
|
+ .font(.system(size: 12, weight: .bold))
|
|
|
|
|
+ .lineLimit(1)
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.92))
|
|
|
|
|
+ .padding(.horizontal, 10)
|
|
|
|
|
+ .padding(.vertical, 9)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
+ .fill(Color.black.opacity(0.24))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
+ .stroke(Color.white.opacity(0.10), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .disabled(isPreview)
|
|
|
|
|
+ .allowsHitTesting(!isPreview)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func open(action: WidgetQuickAction) {
|
|
|
|
|
+ guard !isPreview else { return }
|
|
|
|
|
+ WidgetAppNavigator.open(target: action.destination)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|