feat: Trip Detail screen — real data, Wishlist-quality polish
All checks were successful
Security Checks / dependency-audit (push) Successful in 25s
Security Checks / secret-scanning (push) Successful in 7s
Security Checks / dockerfile-lint (push) Successful in 6s

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:
Yusuf Suleman
2026-04-04 18:35:25 -05:00
parent d680af0547
commit a739e0de80
6 changed files with 382 additions and 2 deletions

View File

@@ -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)")
}
}

View File

@@ -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?
}

View File

@@ -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: {

View 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
])
}
}
}

View File

@@ -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: {