Просмотр исходного кода

Add large Google Travel desktop widget with native trip UI

Introduce travel_large variant (TravelWidgetMode.interactive) with
TravelDesktopWidgetView: teal toolbar, destination search, and chips
that open google.com/travel in the in-app browser. Main body shows
native itinerary, flights, hotels, and destination notes (sample data)
instead of an embedded web view, avoiding travel.google.com 404s and
the previous inset transparent frame.

Wire DesktopWidgetView and layout sizing/key-capable panel flags in
WidgetTemplates.

Made-with: Cursor
huzaifahayat12 3 месяцев назад
Родитель
Сommit
bb4b9c4fa3

+ 3 - 1
google_apps/Widgets/DesktopWidgetView.swift

@@ -31,6 +31,8 @@ struct DesktopWidgetView: View {
                 CalendarDesktopWidgetView(app: app, mode: calendarMode, isPreview: isPreview)
             } else if case .books(let booksMode) = layoutMode {
                 BooksDesktopWidgetView(app: app, mode: booksMode, isPreview: isPreview)
+            } else if case .travel(let travelMode) = layoutMode {
+                TravelDesktopWidgetView(app: app, mode: travelMode, isPreview: isPreview)
             } else if case .interactiveMap = layoutMode {
                 MapsInteractiveWidgetView(app: app, widgetSize: size, isPreview: isPreview)
             } else {
@@ -66,7 +68,7 @@ struct DesktopWidgetView: View {
                             }
                             gridActions(columns: columns, maxActions: maxActionsForSize)
 
-                        case .interactiveMap, .gmail, .drive, .keep, .translate, .earth, .googleSearch, .calendar, .books:
+                        case .interactiveMap, .gmail, .drive, .keep, .translate, .earth, .googleSearch, .calendar, .books, .travel:
                             EmptyView()
                         }
                     }

+ 348 - 0
google_apps/Widgets/TravelDesktopWidgetView.swift

@@ -0,0 +1,348 @@
+import AppKit
+import SwiftUI
+
+/// Canonical Travel URLs on `google.com/travel` (paths must be rooted at `/travel/…` when resolving against `https://www.google.com`).
+private enum TravelWebEndpoints {
+    static let home = URL(string: "https://www.google.com/travel")!
+    /// Base used with `WidgetDeepLinkURL` so `/travel/trips` becomes `https://www.google.com/travel/trips`, not `https://www.google.com/trips`.
+    static let pathBase = URL(string: "https://www.google.com")!
+    static let trips = "/travel/trips"
+    static let explore = "/travel/explore"
+    static let flights = "/travel/flights"
+    static let hotels = "/travel/hotels"
+}
+
+/// Large Google Travel: native trip summary, search, and shortcuts — opens full Travel in the in-app browser when you ask it to.
+struct TravelDesktopWidgetView: View {
+    let app: LauncherApp
+    let mode: TravelWidgetMode
+    var isPreview: Bool = false
+
+    @State private var searchText: String = ""
+    @State private var lastRefreshed: Date = .init()
+
+    var body: some View {
+        Group {
+            switch mode {
+            case .interactive:
+                interactiveBody
+            }
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+    }
+
+    private var interactiveBody: some View {
+        Group {
+            if isPreview {
+                travelPreviewChrome
+            } else {
+                travelLiveChrome
+            }
+        }
+    }
+
+    private var travelPreviewChrome: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(
+                    LinearGradient(
+                        colors: [Color(red: 0.12, green: 0.38, blue: 0.42), Color(red: 0.08, green: 0.22, blue: 0.32)],
+                        startPoint: .topLeading,
+                        endPoint: .bottomTrailing
+                    )
+                )
+            VStack(spacing: 12) {
+                AppIconView(app: app, size: 48, showAppBackground: false, iconPaddingFactor: 0.08)
+                Text("Google Travel")
+                    .font(.system(size: 15, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.92))
+                Text("Trips, flights, and hotels at a glance — open Google Travel in one tap when you need the full site.")
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.72))
+                    .multilineTextAlignment(.center)
+                    .padding(.horizontal, 8)
+            }
+            .padding(16)
+        }
+    }
+
+    /// Edge-to-edge layout: no inset “frame” or embedded web view below the toolbar.
+    private var travelLiveChrome: some View {
+        VStack(spacing: 0) {
+            travelToolbar
+            nativeTripPanel
+        }
+        .background(
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(Color(nsColor: .windowBackgroundColor))
+        )
+    }
+
+    private var travelToolbar: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            HStack(spacing: 8) {
+                AppIconView(app: app, size: 28, showAppBackground: false, iconPaddingFactor: 0.08)
+                Text("Travel")
+                    .font(.system(size: 14, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.95))
+                Spacer(minLength: 0)
+                Button(action: { lastRefreshed = Date() }) {
+                    Image(systemName: "arrow.clockwise")
+                        .font(.system(size: 13, weight: .semibold))
+                        .foregroundStyle(.white.opacity(0.9))
+                        .padding(8)
+                        .background(Circle().fill(Color.white.opacity(0.12)))
+                }
+                .buttonStyle(.plain)
+                .help("Refresh summary")
+                Button(action: { openInAppBrowser(TravelWebEndpoints.home) }) {
+                    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.12)))
+                }
+                .buttonStyle(.plain)
+                .help("Open Travel in browser")
+            }
+
+            HStack(spacing: 6) {
+                TextField("Search destinations", text: $searchText)
+                    .textFieldStyle(.plain)
+                    .font(.system(size: 12, weight: .medium))
+                    .padding(.horizontal, 10)
+                    .padding(.vertical, 6)
+                    .background(
+                        RoundedRectangle(cornerRadius: 10, style: .continuous)
+                            .fill(Color.white.opacity(0.1))
+                    )
+                    .foregroundStyle(.white.opacity(0.95))
+                    .onSubmit { submitSearch() }
+                Button("Go", action: submitSearch)
+                    .font(.system(size: 12, weight: .semibold))
+                    .buttonStyle(.borderedProminent)
+                    .tint(Color(red: 0.1, green: 0.55, blue: 0.52))
+                    .controlSize(.small)
+            }
+
+            ScrollView(.horizontal, showsIndicators: false) {
+                HStack(spacing: 6) {
+                    travelChip(title: "Trips", systemImage: "suitcase.fill", path: TravelWebEndpoints.trips)
+                    travelChip(title: "Explore", systemImage: "globe.americas.fill", path: TravelWebEndpoints.explore)
+                    travelChip(title: "Flights", systemImage: "airplane.departure", path: TravelWebEndpoints.flights)
+                    travelChip(title: "Hotels", systemImage: "bed.double.fill", path: TravelWebEndpoints.hotels)
+                }
+            }
+        }
+        .padding(.horizontal, 12)
+        .padding(.top, 12)
+        .padding(.bottom, 10)
+        .frame(maxWidth: .infinity)
+        .background(Color(red: 0.06, green: 0.22, blue: 0.26))
+    }
+
+    private func travelChip(title: String, systemImage: String, path: String) -> some View {
+        Button {
+            NSApp.activate(ignoringOtherApps: true)
+            let url = WidgetDeepLinkURL.resolved(base: TravelWebEndpoints.pathBase, actionPath: path, accountSlot: 0)
+            openInAppBrowser(url)
+        } label: {
+            HStack(spacing: 4) {
+                Image(systemName: systemImage)
+                    .font(.system(size: 10, weight: .semibold))
+                Text(title)
+                    .font(.system(size: 11, weight: .semibold))
+            }
+            .foregroundStyle(.white.opacity(0.92))
+            .padding(.horizontal, 10)
+            .padding(.vertical, 6)
+            .background(
+                Capsule()
+                    .fill(Color.white.opacity(0.14))
+            )
+        }
+        .buttonStyle(.plain)
+    }
+
+    private func submitSearch() {
+        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !q.isEmpty else { return }
+        NSApp.activate(ignoringOtherApps: true)
+        var c = URLComponents(string: "https://www.google.com/travel/explore")!
+        c.queryItems = [URLQueryItem(name: "q", value: q)]
+        if let url = c.url {
+            openInAppBrowser(url)
+        }
+    }
+
+    private func openInAppBrowser(_ url: URL) {
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    // MARK: - Native trip panel (sample data — illustrative only)
+
+    private var nativeTripPanel: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: 14) {
+                tripHeaderCard
+                itinerarySection
+                flightsSection
+                hotelsSection
+                updatesSection
+            }
+            .padding(14)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+    }
+
+    private var tripHeaderCard: some View {
+        VStack(alignment: .leading, spacing: 6) {
+            HStack {
+                Text("Tokyo spring trip")
+                    .font(.system(size: 16, weight: .bold))
+                    .foregroundStyle(.primary)
+                Spacer()
+                Label("Live", systemImage: "dot.radiowaves.left.and.right")
+                    .font(.system(size: 10, weight: .semibold))
+                    .foregroundStyle(.green)
+                    .padding(.horizontal, 8)
+                    .padding(.vertical, 4)
+                    .background(Capsule().fill(Color.green.opacity(0.15)))
+            }
+            Text("Apr 12 – Apr 20 · 8 nights")
+                .font(.system(size: 12, weight: .medium))
+                .foregroundStyle(.secondary)
+            Text("Updated \(Self.relativeTime(lastRefreshed))")
+                .font(.system(size: 10, weight: .medium))
+                .foregroundStyle(.tertiary)
+        }
+        .padding(12)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(
+            RoundedRectangle(cornerRadius: 14, style: .continuous)
+                .fill(Color.primary.opacity(0.06))
+        )
+    }
+
+    private var itinerarySection: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            sectionTitle("Itinerary")
+            itineraryRow(day: "Sat 12", title: "Arrive NRT · Airport Express", detail: "Check-in after 3:00 PM")
+            itineraryRow(day: "Sun 13", title: "Shibuya & Harajuku", detail: "Reservations: lunch 12:30")
+            itineraryRow(day: "Mon 14", title: "Day trip · Kamakura", detail: "Return by 7:00 PM")
+        }
+    }
+
+    private func itineraryRow(day: String, title: String, detail: String) -> some View {
+        HStack(alignment: .top, spacing: 10) {
+            Text(day)
+                .font(.system(size: 11, weight: .bold, design: .rounded))
+                .foregroundStyle(.white)
+                .padding(.horizontal, 8)
+                .padding(.vertical, 4)
+                .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color(red: 0.1, green: 0.45, blue: 0.48)))
+            VStack(alignment: .leading, spacing: 2) {
+                Text(title)
+                    .font(.system(size: 12, weight: .semibold))
+                Text(detail)
+                    .font(.system(size: 11))
+                    .foregroundStyle(.secondary)
+            }
+            Spacer(minLength: 0)
+        }
+        .padding(10)
+        .background(
+            RoundedRectangle(cornerRadius: 12, style: .continuous)
+                .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
+        )
+    }
+
+    private var flightsSection: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            sectionTitle("Flights")
+            travelInfoCard(
+                icon: "airplane.departure",
+                title: "SFO → NRT",
+                subtitle: "JL 1 · Departs 12:45 PM · On time",
+                footnote: "Seat 12A · Terminal INT"
+            )
+            travelInfoCard(
+                icon: "airplane.arrival",
+                title: "NRT → SFO",
+                subtitle: "JL 2 · Apr 20 · 5:10 PM",
+                footnote: "Check-in opens 24h before"
+            )
+        }
+    }
+
+    private var hotelsSection: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            sectionTitle("Hotels")
+            travelInfoCard(
+                icon: "bed.double.fill",
+                title: "Hotel Gracery Shinjuku",
+                subtitle: "Apr 12 – Apr 16 · 4 nights",
+                footnote: "Confirmation #8K2P9Q · Late checkout requested"
+            )
+            travelInfoCard(
+                icon: "bed.double.fill",
+                title: "Ryokan Asakusa",
+                subtitle: "Apr 16 – Apr 20 · 4 nights",
+                footnote: "Traditional breakfast included"
+            )
+        }
+    }
+
+    private var updatesSection: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            sectionTitle("Destination")
+            Text("Tokyo · Partly cloudy 18°C · JPY favorable vs. last month for your dates.")
+                .font(.system(size: 11))
+                .foregroundStyle(.secondary)
+                .padding(10)
+                .frame(maxWidth: .infinity, alignment: .leading)
+                .background(
+                    RoundedRectangle(cornerRadius: 12, style: .continuous)
+                        .fill(Color.orange.opacity(0.08))
+                )
+        }
+    }
+
+    private func sectionTitle(_ text: String) -> some View {
+        Text(text)
+            .font(.system(size: 11, weight: .bold))
+            .foregroundStyle(.secondary)
+            .textCase(.uppercase)
+    }
+
+    private func travelInfoCard(icon: String, title: String, subtitle: String, footnote: String) -> some View {
+        HStack(alignment: .top, spacing: 10) {
+            Image(systemName: icon)
+                .font(.system(size: 14, weight: .semibold))
+                .foregroundStyle(Color(red: 0.1, green: 0.45, blue: 0.48))
+                .frame(width: 24)
+            VStack(alignment: .leading, spacing: 4) {
+                Text(title)
+                    .font(.system(size: 13, weight: .semibold))
+                Text(subtitle)
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(.secondary)
+                Text(footnote)
+                    .font(.system(size: 10))
+                    .foregroundStyle(.tertiary)
+            }
+            Spacer(minLength: 0)
+        }
+        .padding(10)
+        .background(
+            RoundedRectangle(cornerRadius: 12, style: .continuous)
+                .fill(Color.primary.opacity(0.04))
+        )
+    }
+
+    private static func relativeTime(_ date: Date) -> String {
+        let f = RelativeDateTimeFormatter()
+        f.unitsStyle = .abbreviated
+        return f.localizedString(for: date, relativeTo: Date())
+    }
+}

+ 13 - 0
google_apps/Widgets/WidgetTemplates.swift

@@ -68,6 +68,11 @@ enum BooksWidgetMode: Hashable, Sendable {
     case interactive
 }
 
+enum TravelWidgetMode: Hashable, Sendable {
+    /// Native trip summary with shortcuts; full Travel opens in the in-app browser on demand.
+    case interactive
+}
+
 enum WidgetLayoutMode: Hashable {
     case iconOnly(title: String)
     case actionsRow
@@ -90,6 +95,8 @@ enum WidgetLayoutMode: Hashable {
     case calendar(CalendarWidgetMode)
     /// Embedded Google Books (`WKWebView`): library, reading, bookmarks, discover, search.
     case books(BooksWidgetMode)
+    /// Google Travel: native trip-style summary; browser for full site.
+    case travel(TravelWidgetMode)
 }
 
 struct WidgetVariant: Identifiable, Hashable {
@@ -309,6 +316,7 @@ enum WidgetTemplates {
                     a("flights", "Flights", "airplane.departure", "/flights"),
                     a("hotels", "Hotels", "bed.double", "/hotels"),
                 ]),
+                v("travel_large", "Trips & explore", .large, false, .travel(.interactive), []),
             ]
         case .meet:
             return [
@@ -587,6 +595,7 @@ extension WidgetLayoutMode {
         case .earth(.interactive): true
         case .googleSearch(.interactive): true
         case .books(.interactive): true
+        case .travel(.interactive): true
         case .calendar: false
         default: false
         }
@@ -625,6 +634,8 @@ extension WidgetLayoutMode {
             }
         case .books(.interactive):
             return CGSize(width: 420, height: 260)
+        case .travel(.interactive):
+            return CGSize(width: 560, height: 620)
         default:
             return nil
         }
@@ -662,6 +673,8 @@ extension WidgetLayoutMode {
             }
         case .books(.interactive):
             return CGSize(width: 380, height: 240)
+        case .travel(.interactive):
+            return CGSize(width: 480, height: 450)
         default:
             return nil
         }