feat: Trip Detail screen — real data, Wishlist-quality polish
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
B10067 /* TripImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripImageView.swift; sourceTree = "<group>"; };
|
||||
B10068 /* TripPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripPlaceholderView.swift; sourceTree = "<group>"; };
|
||||
B10069 /* TripDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripDetailView.swift; sourceTree = "<group>"; };
|
||||
B10006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||
B10007 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||
B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||
@@ -463,6 +465,7 @@
|
||||
B10066 /* TripCard.swift */,
|
||||
B10067 /* TripImageView.swift */,
|
||||
B10068 /* TripPlaceholderView.swift */,
|
||||
B10069 /* TripDetailView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
320
ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift
Normal file
320
ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift
Normal file
@@ -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<Content: View>: 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<Style: ShapeStyle>: View {
|
||||
let style: Style
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(style)
|
||||
.mask {
|
||||
MeshGradient(width: 2, height: 2, points: [
|
||||
SIMD2<Float>(0.0, 0.0), SIMD2<Float>(1.0, 0.0),
|
||||
SIMD2<Float>(0.0, 1.0), SIMD2<Float>(1.0, 1.0)
|
||||
], colors: [
|
||||
.clear, .clear,
|
||||
.black, .clear
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user