|
|
@@ -1,25 +1,24 @@
|
|
|
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`.
|
|
|
+ /// Resolves `/travel/…` paths to `https://www.google.com/travel/…`.
|
|
|
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"
|
|
|
+ static let explorePath = "/travel/explore"
|
|
|
+ static let flightsPath = "/travel/flights"
|
|
|
+ static let hotelsPath = "/travel/hotels"
|
|
|
+ /// Vacation rentals live under Hotels with a search hint (`/travel/vacation-rentals` 404s).
|
|
|
+ static let vacationRentalsPath = "/travel/hotels?q=vacation+rentals"
|
|
|
}
|
|
|
|
|
|
-/// Large Google Travel: native trip summary, search, and shortcuts — opens full Travel in the in-app browser when you ask it to.
|
|
|
+/// Large Google Travel: toolbar, search, and four shortcuts (Explore, Flights, Hotels, Vacation rentals). No embedded trip panel.
|
|
|
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 {
|
|
|
@@ -56,7 +55,7 @@ struct TravelDesktopWidgetView: View {
|
|
|
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.")
|
|
|
+ Text("Search destinations or jump to Explore, Flights, Hotels, and Vacation rentals in the in-app browser.")
|
|
|
.font(.system(size: 11, weight: .medium))
|
|
|
.foregroundStyle(.white.opacity(0.72))
|
|
|
.multilineTextAlignment(.center)
|
|
|
@@ -66,16 +65,14 @@ struct TravelDesktopWidgetView: View {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /// 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))
|
|
|
- )
|
|
|
+ travelToolbar
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
+ .background(
|
|
|
+ RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
|
+ .fill(Color(red: 0.06, green: 0.22, blue: 0.26))
|
|
|
+ )
|
|
|
+ .preferredColorScheme(.dark)
|
|
|
}
|
|
|
|
|
|
private var travelToolbar: some View {
|
|
|
@@ -86,7 +83,7 @@ struct TravelDesktopWidgetView: View {
|
|
|
.font(.system(size: 14, weight: .bold))
|
|
|
.foregroundStyle(.white.opacity(0.95))
|
|
|
Spacer(minLength: 0)
|
|
|
- Button(action: { lastRefreshed = Date() }) {
|
|
|
+ Button(action: refreshAction) {
|
|
|
Image(systemName: "arrow.clockwise")
|
|
|
.font(.system(size: 13, weight: .semibold))
|
|
|
.foregroundStyle(.white.opacity(0.9))
|
|
|
@@ -94,7 +91,7 @@ struct TravelDesktopWidgetView: View {
|
|
|
.background(Circle().fill(Color.white.opacity(0.12)))
|
|
|
}
|
|
|
.buttonStyle(.plain)
|
|
|
- .help("Refresh summary")
|
|
|
+ .help("Repeat search, or open Travel home if the field is empty")
|
|
|
Button(action: { openInAppBrowser(TravelWebEndpoints.home) }) {
|
|
|
Image(systemName: "arrow.up.right.square")
|
|
|
.font(.system(size: 13, weight: .semibold))
|
|
|
@@ -125,224 +122,104 @@ struct TravelDesktopWidgetView: View {
|
|
|
.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)
|
|
|
- }
|
|
|
- }
|
|
|
+ travelCategoryRow
|
|
|
}
|
|
|
.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))
|
|
|
+ private var travelCategoryRow: some View {
|
|
|
+ HStack(spacing: 0) {
|
|
|
+ travelCategoryButton(title: "Explore", path: TravelWebEndpoints.explorePath) {
|
|
|
+ exploreCategoryIcon
|
|
|
}
|
|
|
- .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
|
|
|
+ travelCategoryButton(title: "Flights", path: TravelWebEndpoints.flightsPath) {
|
|
|
+ Image(systemName: "airplane")
|
|
|
+ .font(.system(size: 20, weight: .medium))
|
|
|
}
|
|
|
- .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)))
|
|
|
+ travelCategoryButton(title: "Hotels", path: TravelWebEndpoints.hotelsPath) {
|
|
|
+ Image(systemName: "bed.double.fill")
|
|
|
+ .font(.system(size: 20, weight: .medium))
|
|
|
+ }
|
|
|
+ travelCategoryButton(title: "Vacation rentals", path: TravelWebEndpoints.vacationRentalsPath) {
|
|
|
+ Image(systemName: "house.fill")
|
|
|
+ .font(.system(size: 20, weight: .medium))
|
|
|
}
|
|
|
- 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))
|
|
|
- )
|
|
|
+ .padding(.top, 6)
|
|
|
}
|
|
|
|
|
|
- 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")
|
|
|
+ /// Globe + magnifying glass, similar to Google Travel’s Explore affordance.
|
|
|
+ private var exploreCategoryIcon: some View {
|
|
|
+ ZStack {
|
|
|
+ Image(systemName: "globe.americas.fill")
|
|
|
+ .font(.system(size: 19, weight: .medium))
|
|
|
+ Image(systemName: "magnifyingglass")
|
|
|
+ .font(.system(size: 9, weight: .bold))
|
|
|
+ .offset(x: 9, y: 7)
|
|
|
}
|
|
|
+ .frame(width: 28, height: 26)
|
|
|
}
|
|
|
|
|
|
- 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) {
|
|
|
+ private func travelCategoryButton<Icon: View>(
|
|
|
+ title: String,
|
|
|
+ path: String,
|
|
|
+ @ViewBuilder icon: () -> Icon
|
|
|
+ ) -> some View {
|
|
|
+ Button {
|
|
|
+ openTravelPath(path)
|
|
|
+ } label: {
|
|
|
+ VStack(spacing: 5) {
|
|
|
+ icon()
|
|
|
+ .foregroundStyle(Color.white.opacity(0.72))
|
|
|
Text(title)
|
|
|
- .font(.system(size: 12, weight: .semibold))
|
|
|
- Text(detail)
|
|
|
- .font(.system(size: 11))
|
|
|
- .foregroundStyle(.secondary)
|
|
|
+ .font(.system(size: 10, weight: .medium))
|
|
|
+ .foregroundStyle(Color.white.opacity(0.78))
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
+ .lineLimit(2)
|
|
|
+ .minimumScaleFactor(0.85)
|
|
|
}
|
|
|
- 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"
|
|
|
- )
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
+ .padding(.vertical, 6)
|
|
|
+ .contentShape(Rectangle())
|
|
|
}
|
|
|
+ .buttonStyle(.plain)
|
|
|
}
|
|
|
|
|
|
- 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 func openTravelPath(_ path: String) {
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
+ let url = WidgetDeepLinkURL.resolved(base: TravelWebEndpoints.pathBase, actionPath: path, accountSlot: 0)
|
|
|
+ openInAppBrowser(url)
|
|
|
}
|
|
|
|
|
|
- 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 refreshAction() {
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
+ let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if q.isEmpty {
|
|
|
+ openInAppBrowser(TravelWebEndpoints.home)
|
|
|
+ } else {
|
|
|
+ openExplore(query: q)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func sectionTitle(_ text: String) -> some View {
|
|
|
- Text(text)
|
|
|
- .font(.system(size: 11, weight: .bold))
|
|
|
- .foregroundStyle(.secondary)
|
|
|
- .textCase(.uppercase)
|
|
|
+ private func submitSearch() {
|
|
|
+ let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ guard !q.isEmpty else { return }
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
+ openExplore(query: q)
|
|
|
}
|
|
|
|
|
|
- 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)
|
|
|
+ private func openExplore(query q: String) {
|
|
|
+ var c = URLComponents(string: "https://www.google.com/travel/explore")!
|
|
|
+ c.queryItems = [URLQueryItem(name: "q", value: q)]
|
|
|
+ if let url = c.url {
|
|
|
+ openInAppBrowser(url)
|
|
|
}
|
|
|
- .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())
|
|
|
+ private func openInAppBrowser(_ url: URL) {
|
|
|
+ InAppBrowserWindowManager.shared.open(url: url, title: app.name)
|
|
|
}
|
|
|
}
|