|
|
@@ -0,0 +1,371 @@
|
|
|
+import SwiftUI
|
|
|
+import AppKit
|
|
|
+
|
|
|
+struct DesktopWidgetView: View {
|
|
|
+ let variant: WidgetVariant
|
|
|
+ var isPreview: Bool
|
|
|
+ private let authService = GoogleOAuthService.shared
|
|
|
+
|
|
|
+ private var isSignedIn: Bool {
|
|
|
+ if isPreview { return true }
|
|
|
+ return authService.loadTokens() != nil
|
|
|
+ }
|
|
|
+
|
|
|
+ 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.08, green: 0.48, blue: 0.86),
|
|
|
+ Color(red: 0.09, green: 0.66, blue: 0.46)
|
|
|
+ ],
|
|
|
+ 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 ? 30 : 14)
|
|
|
+ .padding(.top, variant.size == .medium ? 34 : (variant.size == .small ? 10 : 14))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @ViewBuilder
|
|
|
+ private var contentBody: some View {
|
|
|
+ if !isSignedIn {
|
|
|
+ loggedOutContent
|
|
|
+ } else {
|
|
|
+ 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: "Open Meet", icon: "video.fill", destination: .openMeetWeb)
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ compactActionButton(title: "Schedule", icon: "clock.badge.checkmark", destination: .schedule)
|
|
|
+ compactActionButton(title: "Settings", icon: "gearshape.fill", destination: .settings)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ case .large:
|
|
|
+ meetingBlock(maxRows: 3)
|
|
|
+ Divider().overlay(Color.white.opacity(0.18))
|
|
|
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 0), spacing: 8), count: 2), spacing: 8) {
|
|
|
+ ForEach(variant.quickActions.prefix(6)) { action in
|
|
|
+ actionTile(action)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private var widgetHeader: some View {
|
|
|
+ HStack(spacing: 10) {
|
|
|
+ Image("HeaderLogo")
|
|
|
+ .resizable()
|
|
|
+ .scaledToFit()
|
|
|
+ .frame(width: 30, height: 30)
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @ViewBuilder
|
|
|
+ private var loggedOutContent: some View {
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
+ Text("Connect Google to use widget actions.")
|
|
|
+ .font(.system(size: 12, weight: .medium))
|
|
|
+ .foregroundStyle(.white.opacity(0.86))
|
|
|
+ Button(action: { open(action: WidgetQuickAction(id: "login", title: "Login", systemImage: "person.crop.circle.badge.checkmark", destination: .signIn)) }) {
|
|
|
+ HStack(spacing: 8) {
|
|
|
+ Image(systemName: "person.crop.circle.badge.checkmark")
|
|
|
+ .font(.system(size: 12, weight: .semibold))
|
|
|
+ Text("Sign in with Google")
|
|
|
+ .font(.system(size: 12.5, weight: .bold))
|
|
|
+ Spacer(minLength: 0)
|
|
|
+ }
|
|
|
+ .foregroundStyle(.white.opacity(0.95))
|
|
|
+ .padding(.horizontal, 12)
|
|
|
+ .padding(.vertical, 10)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
+ .fill(Color.black.opacity(0.24))
|
|
|
+ )
|
|
|
+ .overlay(
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
+ .stroke(Color.white.opacity(0.16), lineWidth: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ .buttonStyle(.plain)
|
|
|
+ .disabled(isPreview)
|
|
|
+ .allowsHitTesting(!isPreview)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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: .schedule)
|
|
|
+ }
|
|
|
+ }) {
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+struct WidgetMeetingSnapshot: Codable, Identifiable {
|
|
|
+ let id: String
|
|
|
+ let title: String
|
|
|
+ let timeText: String
|
|
|
+ let joinLink: String?
|
|
|
+}
|
|
|
+
|
|
|
+enum WidgetMeetingStore {
|
|
|
+ static let key = "meetings.widget.topMeetings"
|
|
|
+
|
|
|
+ static func load() -> [WidgetMeetingSnapshot] {
|
|
|
+ guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
|
|
|
+ return (try? JSONDecoder().decode([WidgetMeetingSnapshot].self, from: data)) ?? []
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+enum WidgetAppNavigator {
|
|
|
+ static func open(target: WidgetQuickAction.Destination) {
|
|
|
+ DispatchQueue.main.async {
|
|
|
+ if target == .refreshMeetings {
|
|
|
+ postTarget(target, delay: 0)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if target == .signIn {
|
|
|
+ postTarget(target, delay: 0)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ bringAppToFront()
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
|
|
+ bringAppToFront()
|
|
|
+ }
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.42) {
|
|
|
+ bringAppToFront()
|
|
|
+ }
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.60) {
|
|
|
+ activateApplicationFallback()
|
|
|
+ bringAppToFront()
|
|
|
+ }
|
|
|
+ postTarget(target, delay: 0.30)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ static func openMeetingLink(_ link: String) {
|
|
|
+ DispatchQueue.main.async {
|
|
|
+ bringAppToFront()
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.20) {
|
|
|
+ bringAppToFront()
|
|
|
+ }
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
|
|
|
+ NotificationCenter.default.post(
|
|
|
+ name: .widgetOpenMeetingLinkRequested,
|
|
|
+ object: nil,
|
|
|
+ userInfo: ["link": link]
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 static func activateApplicationFallback() {
|
|
|
+ let bundleID = Bundle.main.bundleIdentifier ?? "com.mqldev.meetingsapp"
|
|
|
+ let scriptSource = "tell application id \"\(bundleID)\" to activate"
|
|
|
+ if let script = NSAppleScript(source: scriptSource) {
|
|
|
+ var errorInfo: NSDictionary?
|
|
|
+ script.executeAndReturnError(&errorInfo)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func postTarget(_ target: WidgetQuickAction.Destination, delay: TimeInterval) {
|
|
|
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
|
+ switch target {
|
|
|
+ case .joinMeetings:
|
|
|
+ NotificationCenter.default.post(name: .widgetOpenJoinMeetingsPage, object: nil)
|
|
|
+ case .schedule:
|
|
|
+ NotificationCenter.default.post(name: .widgetOpenSchedulePage, object: nil)
|
|
|
+ case .calendar:
|
|
|
+ NotificationCenter.default.post(name: .widgetOpenCalendarPage, object: nil)
|
|
|
+ case .settings:
|
|
|
+ NotificationCenter.default.post(name: .widgetOpenSettingsPage, object: nil)
|
|
|
+ case .signIn:
|
|
|
+ NotificationCenter.default.post(name: .widgetSignInRequested, object: nil)
|
|
|
+ case .openMeetWeb:
|
|
|
+ NotificationCenter.default.post(name: .widgetOpenMeetWebRequested, object: nil)
|
|
|
+ case .refreshMeetings:
|
|
|
+ NotificationCenter.default.post(name: .widgetRefreshRequested, object: nil)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+extension Notification.Name {
|
|
|
+ static let widgetOpenJoinMeetingsPage = Notification.Name("widgetOpenJoinMeetingsPage")
|
|
|
+ static let widgetOpenSchedulePage = Notification.Name("widgetOpenSchedulePage")
|
|
|
+ static let widgetOpenCalendarPage = Notification.Name("widgetOpenCalendarPage")
|
|
|
+ static let widgetOpenSettingsPage = Notification.Name("widgetOpenSettingsPage")
|
|
|
+ static let widgetSignInRequested = Notification.Name("widgetSignInRequested")
|
|
|
+ static let widgetOpenMeetWebRequested = Notification.Name("widgetOpenMeetWebRequested")
|
|
|
+ static let widgetRefreshRequested = Notification.Name("widgetRefreshRequested")
|
|
|
+ static let widgetOpenMeetingLinkRequested = Notification.Name("widgetOpenMeetingLinkRequested")
|
|
|
+}
|