From a739e0de80a3f29152dbccc491ddd2b08f073fc9 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Sat, 4 Apr 2026 18:35:25 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Trip=20Detail=20screen=20=E2=80=94=20re?= =?UTF-8?q?al=20data,=20Wishlist-quality=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapted from Apple's Wishlist TripDetailView: - 510pt hero image with .scrollEdgeEffectStyle(.soft) - Trip stats overlay (glass material + MeshGradient, same pattern) - Title in .largeTitle toolbar (expanded font) - .toolbarRole(.editor) for clean back navigation Content sections with real API data: - Lodging: hotel name, location, check-in/out dates - Flights & Transport: route, type icon, flight number - Places: name, category, visit date - AI Recommendations: first 500 chars of suggestions - Empty state when no details added Models: TripDetail, TripTransportation, TripLodging, TripLocation, TripNote — matching the /api/trip/{id} response shape GradientOverlay: MeshGradient mask from Wishlist's GradientView Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Platform.xcodeproj/project.pbxproj | 4 + .../Features/Trips/API/TripsAPI.swift | 4 + .../Features/Trips/Models/TripModels.swift | 52 +++ .../Trips/Views/PastTripsSection.swift | 2 +- .../Features/Trips/Views/TripDetailView.swift | 320 ++++++++++++++++++ .../Trips/Views/UpcomingTripsPageView.swift | 2 +- 6 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift diff --git a/ios/Platform/Platform.xcodeproj/project.pbxproj b/ios/Platform/Platform.xcodeproj/project.pbxproj index f726b47..e9283f5 100644 --- a/ios/Platform/Platform.xcodeproj/project.pbxproj +++ b/ios/Platform/Platform.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ A10066 /* TripCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10066 /* TripCard.swift */; }; A10067 /* TripImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10067 /* TripImageView.swift */; }; A10068 /* TripPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10068 /* TripPlaceholderView.swift */; }; + A10069 /* TripDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10069 /* TripDetailView.swift */; }; A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006 /* LoginView.swift */; }; A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; }; A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008 /* HomeViewModel.swift */; }; @@ -108,6 +109,7 @@ B10066 /* TripCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripCard.swift; sourceTree = ""; }; B10067 /* TripImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripImageView.swift; sourceTree = ""; }; B10068 /* TripPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripPlaceholderView.swift; sourceTree = ""; }; + B10069 /* TripDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripDetailView.swift; sourceTree = ""; }; B10006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; B10007 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; @@ -463,6 +465,7 @@ B10066 /* TripCard.swift */, B10067 /* TripImageView.swift */, B10068 /* TripPlaceholderView.swift */, + B10069 /* TripDetailView.swift */, ); path = Views; sourceTree = ""; @@ -594,6 +597,7 @@ A10066 /* TripCard.swift in Sources */, A10067 /* TripImageView.swift in Sources */, A10068 /* TripPlaceholderView.swift in Sources */, + A10069 /* TripDetailView.swift in Sources */, A10006 /* LoginView.swift in Sources */, A10007 /* HomeView.swift in Sources */, A10008 /* HomeViewModel.swift in Sources */, diff --git a/ios/Platform/Platform/Features/Trips/API/TripsAPI.swift b/ios/Platform/Platform/Features/Trips/API/TripsAPI.swift index ba3c82d..c8bafe9 100644 --- a/ios/Platform/Platform/Features/Trips/API/TripsAPI.swift +++ b/ios/Platform/Platform/Features/Trips/API/TripsAPI.swift @@ -12,4 +12,8 @@ struct TripsAPI { func getTrip(id: String) async throws -> Trip { try await api.get("\(basePath)/trip/\(id)") } + + func getTripDetail(id: String) async throws -> TripDetail { + try await api.get("\(basePath)/trip/\(id)") + } } diff --git a/ios/Platform/Platform/Features/Trips/Models/TripModels.swift b/ios/Platform/Platform/Features/Trips/Models/TripModels.swift index 848a371..fea6192 100644 --- a/ios/Platform/Platform/Features/Trips/Models/TripModels.swift +++ b/ios/Platform/Platform/Features/Trips/Models/TripModels.swift @@ -75,3 +75,55 @@ struct Trip: Codable, Identifiable, Hashable { struct TripsResponse: Codable { let trips: [Trip] } + +// MARK: - Trip Detail (full response from /api/trip/{id}) + +struct TripDetail: Codable { + let id: String + let name: String + let startDate: String + let endDate: String + let transportations: [TripTransportation] + let lodging: [TripLodging] + let locations: [TripLocation] + let notes: [TripNote] + let aiSuggestions: String? +} + +struct TripTransportation: Codable, Identifiable { + let id: String + let name: String + let type: String? + let flightNumber: String? + let fromLocation: String? + let toLocation: String? + let date: String? + let endDate: String? + let startTime: String? + let endTime: String? +} + +struct TripLodging: Codable, Identifiable { + let id: String + let name: String + let location: String? + let checkIn: String? + let checkOut: String? + let reservationNumber: String? +} + +struct TripLocation: Codable, Identifiable { + let id: String + let name: String + let category: String? + let visitDate: String? + let address: String? + let latitude: Double? + let longitude: Double? +} + +struct TripNote: Codable, Identifiable { + let id: String + let title: String? + let content: String? +} diff --git a/ios/Platform/Platform/Features/Trips/Views/PastTripsSection.swift b/ios/Platform/Platform/Features/Trips/Views/PastTripsSection.swift index b907e88..f726d5d 100644 --- a/ios/Platform/Platform/Features/Trips/Views/PastTripsSection.swift +++ b/ios/Platform/Platform/Features/Trips/Views/PastTripsSection.swift @@ -13,7 +13,7 @@ struct PastTripsSection: View { HStack(spacing: 12) { ForEach(trips) { trip in NavigationLink { - TripPlaceholderView(trip: trip) + TripDetailView(trip: trip) .navigationTransition( .zoom(sourceID: trip.id, in: namespace)) } label: { diff --git a/ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift b/ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift new file mode 100644 index 0000000..2006469 --- /dev/null +++ b/ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift @@ -0,0 +1,320 @@ +import SwiftUI + +/// Displays detailed information about a trip. +/// Adapted from Apple's Wishlist TripDetailView. +/// +/// Layout: +/// 1. Tall hero image with trip stats overlay (glass material) +/// 2. Title in largeTitle toolbar (expanded font) +/// 3. Content sections: lodging, flights, places +struct TripDetailView: View { + let trip: Trip + @State private var detail: TripDetail? + @State private var isLoading = true + + var body: some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 20) { + // Hero image — 510pt, same as Wishlist + TripImageView(url: trip.imageURL, fallbackName: trip.name) + .scaledToFill() + .containerRelativeFrame(.horizontal) + .frame(height: 510) + .clipped() + .overlay(alignment: .bottomLeading) { + tripStatsOverlay + } + + // Content sections + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else if let detail { + // Lodging + if !detail.lodging.isEmpty { + TripSection(title: "Lodging", icon: "bed.double.fill") { + ForEach(detail.lodging) { item in + LodgingCard(item: item) + } + } + } + + // Transportation + if !detail.transportations.isEmpty { + TripSection(title: "Flights & Transport", icon: "airplane") { + ForEach(detail.transportations) { item in + TransportCard(item: item) + } + } + } + + // Locations + if !detail.locations.isEmpty { + TripSection(title: "Places", icon: "mappin.and.ellipse") { + ForEach(detail.locations) { item in + LocationCard(item: item) + } + } + } + + // AI Suggestions + if let ai = detail.aiSuggestions, !ai.isEmpty { + TripSection(title: "AI Recommendations", icon: "sparkles") { + Text(ai.prefix(500) + (ai.count > 500 ? "..." : "")) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .lineSpacing(4) + } + } + + // Empty state + if detail.lodging.isEmpty && detail.transportations.isEmpty && detail.locations.isEmpty { + VStack(spacing: 16) { + Image(systemName: "map.fill") + .font(.system(size: 40)) + .foregroundStyle(Color.accentWarm.opacity(0.3)) + Text("No details yet") + .font(.headline) + .foregroundStyle(Color.textSecondary) + Text("Add lodging, flights, and places on the web") + .font(.subheadline) + .foregroundStyle(Color.textTertiary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + } + } + } + .contentMargins(.bottom, 30, for: .scrollContent) + .scrollEdgeEffectStyle(.soft, for: .top) + .background(Color.canvas) + .toolbar { + ToolbarItem(placement: .largeTitle) { + HStack { + Text(trip.name) + .font(.headline) + .fontWeight(.medium) + .fontWidth(.expanded) + .fixedSize() + Spacer() + } + } + } + .ignoresSafeArea(edges: .top) + .toolbarTitleDisplayMode(.inline) + .navigationTitle(trip.name) + .toolbarRole(.editor) + .task { + await loadDetail() + } + } + + // MARK: - Trip Stats Overlay + + private var tripStatsOverlay: some View { + VStack(alignment: .leading, spacing: 4) { + Text(trip.dateRange) + .font(.title3) + .fontWidth(.expanded) + .fontWeight(.regular) + + HStack(spacing: 16) { + if !trip.tripLength.isEmpty { + Label(trip.tripLength, systemImage: "calendar") + .font(.footnote) + .fontWidth(.condensed) + } + if let d = detail { + if !d.lodging.isEmpty { + Label("^[\(d.lodging.count) STAYS](inflect: true)", systemImage: "bed.double") + .font(.footnote) + .fontWidth(.condensed) + } + if !d.transportations.isEmpty { + Label("^[\(d.transportations.count) FLIGHTS](inflect: true)", systemImage: "airplane") + .font(.footnote) + .fontWidth(.condensed) + } + if !d.locations.isEmpty { + Label("^[\(d.locations.count) PLACES](inflect: true)", systemImage: "mappin") + .font(.footnote) + .fontWidth(.condensed) + } + } + } + .foregroundStyle(.secondary) + } + .padding() + .background { + GradientOverlay(style: .ultraThinMaterial) + } + } + + // MARK: - Load Detail + + private func loadDetail() async { + do { + detail = try await TripsAPI().getTripDetail(id: trip.id) + } catch {} + isLoading = false + } +} + +// MARK: - Section Container + +struct TripSection: View { + let title: String + let icon: String + @ViewBuilder let content: () -> Content + + var body: some View { + Section { + content() + } header: { + HStack(spacing: 6) { + Image(systemName: icon) + .foregroundStyle(Color.accentWarm) + Text(title) + .font(.title3) + .fontWidth(.expanded) + } + } + .padding(.horizontal, 16) + } +} + +// MARK: - Lodging Card + +struct LodgingCard: View { + let item: TripLodging + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(item.name) + .font(.body.weight(.medium)) + + if let location = item.location, !location.isEmpty { + Label(location, systemImage: "location") + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if let checkIn = item.checkIn { + HStack { + Text(Trip.formatDisplay(String(checkIn.prefix(10)))) + .font(.caption) + .foregroundStyle(.secondary) + if let checkOut = item.checkOut { + Text("→") + .font(.caption) + .foregroundStyle(.tertiary) + Text(Trip.formatDisplay(String(checkOut.prefix(10)))) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.surfaceCard) + .clipShape(.rect(cornerRadius: 12)) + } +} + +// MARK: - Transport Card + +struct TransportCard: View { + let item: TripTransportation + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(item.name) + .font(.body.weight(.medium)) + Spacer() + if let type = item.type { + Image(systemName: type == "plane" ? "airplane" : "car.fill") + .foregroundStyle(Color.accentWarm) + } + } + + if let from = item.fromLocation, let to = item.toLocation { + HStack { + Text(from) + .font(.subheadline) + .foregroundStyle(.secondary) + Image(systemName: "arrow.right") + .font(.caption) + .foregroundStyle(.tertiary) + Text(to) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + if let flight = item.flightNumber, !flight.isEmpty { + Text(flight) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.surfaceCard) + .clipShape(.rect(cornerRadius: 12)) + } +} + +// MARK: - Location Card + +struct LocationCard: View { + let item: TripLocation + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(item.name) + .font(.body.weight(.medium)) + + if let category = item.category, !category.isEmpty { + Text(category.uppercased()) + .font(.caption2) + .fontWidth(.condensed) + .foregroundStyle(Color.accentWarm) + } + + if let date = item.visitDate, !date.isEmpty { + Text(Trip.formatDisplay(date)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.surfaceCard) + .clipShape(.rect(cornerRadius: 12)) + } +} + +// MARK: - Gradient Overlay (from Wishlist GradientView) + +struct GradientOverlay: View { + let style: Style + + var body: some View { + Rectangle() + .fill(style) + .mask { + MeshGradient(width: 2, height: 2, points: [ + SIMD2(0.0, 0.0), SIMD2(1.0, 0.0), + SIMD2(0.0, 1.0), SIMD2(1.0, 1.0) + ], colors: [ + .clear, .clear, + .black, .clear + ]) + } + } +} diff --git a/ios/Platform/Platform/Features/Trips/Views/UpcomingTripsPageView.swift b/ios/Platform/Platform/Features/Trips/Views/UpcomingTripsPageView.swift index 0fc0749..b2c7a8a 100644 --- a/ios/Platform/Platform/Features/Trips/Views/UpcomingTripsPageView.swift +++ b/ios/Platform/Platform/Features/Trips/Views/UpcomingTripsPageView.swift @@ -28,7 +28,7 @@ struct UpcomingTripsPageView: View { TabView { ForEach(trips) { trip in NavigationLink { - TripPlaceholderView(trip: trip) + TripDetailView(trip: trip) .navigationTransition( .zoom(sourceID: trip.id, in: namespace)) } label: {