|
@@ -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"),
|
|
|
|
|
+ ]
|
|
|
|
|
+}
|