Bläddra i källkod

Add Google News desktop widgets for medium and large sizes

Introduce NewsWidgetMode (headlines, picksHub) and NewsDesktopWidgetView:
search with in-app Google News, sample headline lists, topic grid on large,
and Picks for You rows that open coverage via news search. Wire layout mode
in DesktopWidgetView and panel sizing. Include minor Info.plist key order
cleanup in the Xcode project file.

Made-with: Cursor
huzaifahayat12 3 månader sedan
förälder
incheckning
722f4d1351

+ 2 - 2
google_apps.xcodeproj/project.pbxproj

@@ -255,9 +255,9 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_NSCalendarsUsageDescription = "The Calendar widget shows upcoming events from Apple Calendar (including calendars synced with Google) and opens Google Calendar for scheduling.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Google Maps uses your location to center the map and suggest nearby places.";
-				INFOPLIST_KEY_NSCalendarsUsageDescription = "The Calendar widget shows upcoming events from Apple Calendar (including calendars synced with Google) and opens Google Calendar for scheduling.";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				LD_RUNPATH_SEARCH_PATHS = (
@@ -289,9 +289,9 @@
 				ENABLE_APP_SANDBOX = YES;
 				ENABLE_USER_SELECTED_FILES = readonly;
 				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_NSCalendarsUsageDescription = "The Calendar widget shows upcoming events from Apple Calendar (including calendars synced with Google) and opens Google Calendar for scheduling.";
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Google Maps uses your location to center the map and suggest nearby places.";
-				INFOPLIST_KEY_NSCalendarsUsageDescription = "The Calendar widget shows upcoming events from Apple Calendar (including calendars synced with Google) and opens Google Calendar for scheduling.";
 				INFOPLIST_KEY_NSMainStoryboardFile = Main;
 				INFOPLIST_KEY_NSPrincipalClass = NSApplication;
 				LD_RUNPATH_SEARCH_PATHS = (

+ 3 - 1
google_apps/Widgets/DesktopWidgetView.swift

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

+ 335 - 0
google_apps/Widgets/NewsDesktopWidgetView.swift

@@ -0,0 +1,335 @@
+import AppKit
+import SwiftUI
+
+private let newsPanelBackground = Color(red: 0.07, green: 0.09, blue: 0.12)
+private let newsAccent = Color(red: 0.26, green: 0.52, blue: 0.96)
+
+/// Google News: medium = search + top headlines; large = search + topic shortcuts + “Picks for You”.
+/// Story taps open Google News search for that headline (full coverage) in the in-app browser.
+struct NewsDesktopWidgetView: View {
+    let app: LauncherApp
+    let mode: NewsWidgetMode
+    var isPreview: Bool = false
+
+    @State private var searchText: String = ""
+
+    private var baseURL: URL { app.webURL ?? URL(string: "https://news.google.com")! }
+
+    var body: some View {
+        Group {
+            switch mode {
+            case .headlines:
+                headlinesRoot
+            case .picksHub:
+                picksHubRoot
+            }
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+    }
+
+    // MARK: - Medium
+
+    private var headlinesRoot: some View {
+        Group {
+            if isPreview {
+                newsPreviewChrome(
+                    title: "Headlines & search",
+                    subtitle: "Search the news and browse top headlines; stories open in Google News."
+                )
+            } else {
+                headlinesLive
+            }
+        }
+    }
+
+    private var headlinesLive: some View {
+        VStack(alignment: .leading, spacing: 10) {
+            newsHeaderRow(showRefresh: false)
+
+            newsSearchRow
+
+            Text("Top headlines")
+                .font(.system(size: 10, weight: .bold))
+                .foregroundStyle(.white.opacity(0.45))
+                .textCase(.uppercase)
+
+            ScrollView {
+                VStack(alignment: .leading, spacing: 6) {
+                    ForEach(Self.topHeadlines) { item in
+                        headlineButton(item)
+                    }
+                }
+                .frame(maxWidth: .infinity, alignment: .leading)
+            }
+            .frame(maxHeight: .infinity)
+        }
+        .padding(14)
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .background(
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(newsPanelBackground)
+        )
+        .preferredColorScheme(.dark)
+    }
+
+    // MARK: - Large
+
+    private var picksHubRoot: some View {
+        Group {
+            if isPreview {
+                newsPreviewChrome(
+                    title: "Picks & topics",
+                    subtitle: "Search, jump to topics, and open stories from Picks for You."
+                )
+            } else {
+                picksHubLive
+            }
+        }
+    }
+
+    private var picksHubLive: some View {
+        VStack(alignment: .leading, spacing: 10) {
+            newsHeaderRow(showRefresh: true)
+
+            newsSearchRow
+
+            Text("Topics")
+                .font(.system(size: 10, weight: .bold))
+                .foregroundStyle(.white.opacity(0.45))
+                .textCase(.uppercase)
+
+            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
+                newsTopicPill(title: "World", systemImage: "globe.americas.fill", query: "World news")
+                newsTopicPill(title: "Local", systemImage: "mappin.and.ellipse", query: "Local news")
+                newsTopicPill(title: "Business", systemImage: "briefcase.fill", query: "Business news")
+                newsTopicPill(title: "Technology", systemImage: "cpu", query: "Technology news")
+            }
+
+            Text("Picks for you")
+                .font(.system(size: 10, weight: .bold))
+                .foregroundStyle(.white.opacity(0.45))
+                .textCase(.uppercase)
+                .padding(.top, 2)
+
+            ScrollView {
+                VStack(alignment: .leading, spacing: 6) {
+                    ForEach(Self.picksForYou) { item in
+                        pickRowButton(item)
+                    }
+                }
+                .frame(maxWidth: .infinity, alignment: .leading)
+            }
+            .frame(maxHeight: .infinity)
+        }
+        .padding(14)
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .background(
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(newsPanelBackground)
+        )
+        .preferredColorScheme(.dark)
+    }
+
+    // MARK: - Shared pieces
+
+    private func newsHeaderRow(showRefresh: Bool) -> some View {
+        HStack(spacing: 8) {
+            AppIconView(app: app, size: 28, showAppBackground: false, iconPaddingFactor: 0.08)
+            Text("Google News")
+                .font(.system(size: 14, weight: .bold))
+                .foregroundStyle(.white.opacity(0.95))
+            Spacer(minLength: 0)
+            if showRefresh {
+                Button(action: { openNewsPath("/topstories") }) {
+                    Image(systemName: "arrow.clockwise")
+                        .font(.system(size: 13, weight: .semibold))
+                        .foregroundStyle(.white.opacity(0.9))
+                        .padding(8)
+                        .background(Circle().fill(Color.white.opacity(0.1)))
+                }
+                .buttonStyle(.plain)
+                .help("Open Headlines in Google News")
+            }
+            Button(action: { openInAppBrowser(baseURL) }) {
+                Image(systemName: "arrow.up.right.square")
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.9))
+                    .padding(8)
+                    .background(Circle().fill(Color.white.opacity(0.1)))
+            }
+            .buttonStyle(.plain)
+            .help("Open Google News")
+        }
+    }
+
+    private var newsSearchRow: some View {
+        HStack(spacing: 8) {
+            TextField("Search news", text: $searchText)
+                .textFieldStyle(.plain)
+                .font(.system(size: 13, weight: .medium))
+                .padding(.horizontal, 12)
+                .padding(.vertical, 8)
+                .background(
+                    RoundedRectangle(cornerRadius: 12, style: .continuous)
+                        .fill(Color.white.opacity(0.1))
+                )
+                .foregroundStyle(.white.opacity(0.95))
+                .onSubmit { submitSearch() }
+            Button("Search", action: submitSearch)
+                .font(.system(size: 12, weight: .semibold))
+                .buttonStyle(.borderedProminent)
+                .tint(newsAccent)
+                .controlSize(.regular)
+        }
+    }
+
+    private func headlineButton(_ item: NewsStoryItem) -> some View {
+        Button {
+            openStory(title: item.title)
+        } label: {
+            VStack(alignment: .leading, spacing: 4) {
+                Text(item.title)
+                    .font(.system(size: 12, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.95))
+                    .multilineTextAlignment(.leading)
+                    .lineLimit(3)
+                if let meta = item.meta {
+                    Text(meta)
+                        .font(.system(size: 10, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.45))
+                }
+            }
+            .frame(maxWidth: .infinity, alignment: .leading)
+            .padding(10)
+            .background(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .fill(Color.white.opacity(0.06))
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
+            )
+        }
+        .buttonStyle(.plain)
+    }
+
+    private func pickRowButton(_ item: NewsStoryItem) -> some View {
+        headlineButton(item)
+    }
+
+    private func newsTopicPill(title: String, systemImage: String, query: String) -> some View {
+        Button {
+            openNewsSearch(query: query)
+        } label: {
+            HStack(spacing: 8) {
+                Image(systemName: systemImage)
+                    .font(.system(size: 14, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.88))
+                Text(title)
+                    .font(.system(size: 12, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.9))
+                    .lineLimit(1)
+                Spacer(minLength: 0)
+            }
+            .padding(.horizontal, 10)
+            .padding(.vertical, 10)
+            .background(
+                RoundedRectangle(cornerRadius: 14, style: .continuous)
+                    .fill(Color.white.opacity(0.08))
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: 14, style: .continuous)
+                    .strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
+            )
+        }
+        .buttonStyle(.plain)
+    }
+
+    private func newsPreviewChrome(title: String, subtitle: String) -> some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(
+                    LinearGradient(
+                        colors: [newsAccent.opacity(0.75), newsPanelBackground],
+                        startPoint: .topLeading,
+                        endPoint: .bottomTrailing
+                    )
+                )
+            VStack(spacing: 12) {
+                AppIconView(app: app, size: 48, showAppBackground: false, iconPaddingFactor: 0.08)
+                Text(title)
+                    .font(.system(size: 15, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.95))
+                Text(subtitle)
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.75))
+                    .multilineTextAlignment(.center)
+                    .padding(.horizontal, 8)
+            }
+            .padding(16)
+        }
+    }
+
+    // MARK: - URLs
+
+    private func submitSearch() {
+        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !q.isEmpty else { return }
+        openNewsSearch(query: q)
+    }
+
+    private func openStory(title: String) {
+        openNewsSearch(query: title)
+    }
+
+    private func openNewsSearch(query: String) {
+        let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !q.isEmpty else { return }
+        NSApp.activate(ignoringOtherApps: true)
+        guard var c = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { return }
+        c.path = "/search"
+        c.queryItems = [
+            URLQueryItem(name: "q", value: q),
+            URLQueryItem(name: "hl", value: "en-US"),
+            URLQueryItem(name: "gl", value: "US"),
+            URLQueryItem(name: "ceid", value: "US:en"),
+        ]
+        if let url = c.url {
+            openInAppBrowser(url)
+        }
+    }
+
+    private func openNewsPath(_ path: String) {
+        NSApp.activate(ignoringOtherApps: true)
+        let url = WidgetDeepLinkURL.resolved(base: baseURL, actionPath: path, accountSlot: 0)
+        openInAppBrowser(url)
+    }
+
+    private func openInAppBrowser(_ url: URL) {
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    // MARK: - Sample content (offline; mirrors typical Home feed)
+
+    private struct NewsStoryItem: Identifiable {
+        let id: String
+        let title: String
+        let meta: String?
+    }
+
+    private static let topHeadlines: [NewsStoryItem] = [
+        NewsStoryItem(id: "h1", title: "Central banks signal cautious stance as markets digest inflation data", meta: "Reuters · 2h"),
+        NewsStoryItem(id: "h2", title: "Tech giants outline AI safety commitments ahead of global summit", meta: "AP News · 3h"),
+        NewsStoryItem(id: "h3", title: "Regional cities see renewed investment in public transit projects", meta: "Bloomberg · 4h"),
+        NewsStoryItem(id: "h4", title: "Healthcare sector weighs new guidelines for seasonal care", meta: "The Guardian · 5h"),
+        NewsStoryItem(id: "h5", title: "Sports leagues expand streaming partnerships for international fans", meta: "ESPN · 6h"),
+    ]
+
+    private static let picksForYou: [NewsStoryItem] = [
+        NewsStoryItem(id: "p1", title: "Why urban planners are prioritizing walkable neighborhoods in 2026", meta: "For you · Science & climate"),
+        NewsStoryItem(id: "p2", title: "Startups bet on smaller, more efficient chips for edge devices", meta: "For you · Tech"),
+        NewsStoryItem(id: "p3", title: "What changing interest rates could mean for first-time buyers", meta: "For you · Business"),
+        NewsStoryItem(id: "p4", title: "Five destinations seeing a surge in off-season travel", meta: "For you · World"),
+        NewsStoryItem(id: "p5", title: "New studies explore sleep habits and weekday productivity", meta: "For you · Health"),
+    ]
+}

+ 22 - 6
google_apps/Widgets/WidgetTemplates.swift

@@ -89,6 +89,13 @@ enum MeetWidgetMode: Hashable, Sendable {
     case hub
 }
 
+enum NewsWidgetMode: Hashable, Sendable {
+    /// Search bar plus top headlines (local sample; opens Google News in-app).
+    case headlines
+    /// Search, topic shortcuts, and “Picks for You” with tappable titles.
+    case picksHub
+}
+
 enum WidgetLayoutMode: Hashable {
     case iconOnly(title: String)
     case actionsRow
@@ -117,6 +124,8 @@ enum WidgetLayoutMode: Hashable {
     case youtube(YoutubeWidgetMode)
     /// Google Meet: Calendar-backed Meet links; join/create via in-app browser.
     case meet(MeetWidgetMode)
+    /// Google News: search + headlines (medium) or topics + picks (large); opens in-app browser.
+    case news(NewsWidgetMode)
 }
 
 struct WidgetVariant: Identifiable, Hashable {
@@ -364,12 +373,8 @@ enum WidgetTemplates {
         case .news:
             return [
                 v("news_small", "News", .small, false, .iconOnly(title: "News"), [a("home", "Headlines", "newspaper.fill", "/home")]),
-                v("news_medium", "News Feeds", .medium, true, .actionsGrid(columns: 2), [
-                    a("foryou", "For you", "person.crop.circle", "/foryou"),
-                    a("headlines", "Headlines", "newspaper", "/topstories"),
-                    a("topics", "Topics", "square.grid.2x2", "/topics"),
-                    a("search", "Search", "magnifyingglass", "/search"),
-                ]),
+                v("news_medium", "Headlines & search", .medium, false, .news(.headlines), []),
+                v("news_large", "Picks & topics", .large, false, .news(.picksHub), []),
             ]
         case .chat:
             return [
@@ -593,6 +598,7 @@ extension WidgetLayoutMode {
         case .travel(.interactive): true
         case .youtube(.searchBar): true
         case .youtube(.interactiveNav): true
+        case .news(_): true
         case .meet: false
         case .calendar: false
         default: false
@@ -644,6 +650,11 @@ extension WidgetLayoutMode {
             case .agenda: return CGSize(width: 400, height: 280)
             case .hub: return CGSize(width: 440, height: 520)
             }
+        case .news(let mode):
+            switch mode {
+            case .headlines: return CGSize(width: 420, height: 340)
+            case .picksHub: return CGSize(width: 440, height: 520)
+            }
         default:
             return nil
         }
@@ -693,6 +704,11 @@ extension WidgetLayoutMode {
             case .agenda: return CGSize(width: 360, height: 250)
             case .hub: return CGSize(width: 380, height: 480)
             }
+        case .news(let mode):
+            switch mode {
+            case .headlines: return CGSize(width: 380, height: 300)
+            case .picksHub: return CGSize(width: 380, height: 480)
+            }
         default:
             return nil
         }