|
@@ -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())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|