feat: Trip Detail as chronological timeline (matches web dashboard)
Replaced type-grouped sections with a unified timeline: - All items (flights, lodging, places) sorted by date + time - Grouped by day with "Tuesday, Jan 20" headers - Vertical timeline line connecting items within each day - Color-coded icons: blue (flights), warm (lodging), red (places) - Time shown in 12h format (8:50 AM) - Category badges for places - Subtitle with route/location details Same layout order as the web dashboard — not grouped by type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,7 @@ struct TripLocation: Codable, Identifiable {
|
|||||||
let address: String?
|
let address: String?
|
||||||
let latitude: Double?
|
let latitude: Double?
|
||||||
let longitude: Double?
|
let longitude: Double?
|
||||||
|
let startTime: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TripNote: Codable, Identifiable {
|
struct TripNote: Codable, Identifiable {
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Displays detailed information about a trip.
|
/// Displays detailed information about a trip as a chronological timeline.
|
||||||
/// Adapted from Apple's Wishlist TripDetailView.
|
/// All items (flights, lodging, places) sorted by date/time — same as the web dashboard.
|
||||||
///
|
|
||||||
/// 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 {
|
struct TripDetailView: View {
|
||||||
let trip: Trip
|
let trip: Trip
|
||||||
@State private var detail: TripDetail?
|
@State private var detail: TripDetail?
|
||||||
@@ -14,8 +9,8 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
// Hero image — 510pt, same as Wishlist
|
// Hero image — 510pt, Wishlist pattern
|
||||||
TripImageView(url: trip.imageURL, fallbackName: trip.name)
|
TripImageView(url: trip.imageURL, fallbackName: trip.name)
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.containerRelativeFrame(.horizontal)
|
.containerRelativeFrame(.horizontal)
|
||||||
@@ -25,64 +20,17 @@ struct TripDetailView: View {
|
|||||||
tripStatsOverlay
|
tripStatsOverlay
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content sections
|
// Timeline content
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 40)
|
.padding(.vertical, 60)
|
||||||
} else if let detail {
|
} else if let detail {
|
||||||
// Lodging
|
let timeline = buildTimeline(detail)
|
||||||
if !detail.lodging.isEmpty {
|
if timeline.isEmpty {
|
||||||
TripSection(title: "Lodging", icon: "bed.double.fill") {
|
emptyState
|
||||||
ForEach(detail.lodging) { item in
|
} else {
|
||||||
LodgingCard(item: item)
|
timelineView(timeline)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +59,178 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Trip Stats Overlay
|
// MARK: - Timeline Builder
|
||||||
|
|
||||||
|
private func buildTimeline(_ detail: TripDetail) -> [(String, [TimelineItem])] {
|
||||||
|
var items: [TimelineItem] = []
|
||||||
|
|
||||||
|
for t in detail.transportations {
|
||||||
|
let date = t.date ?? ""
|
||||||
|
let time = t.startTime ?? ""
|
||||||
|
let subtitle: String = {
|
||||||
|
var parts: [String] = []
|
||||||
|
if let from = t.fromLocation, !from.isEmpty,
|
||||||
|
let to = t.toLocation, !to.isEmpty {
|
||||||
|
parts.append("\(from) → \(to)")
|
||||||
|
}
|
||||||
|
if let flight = t.flightNumber, !flight.isEmpty {
|
||||||
|
parts.append(flight)
|
||||||
|
}
|
||||||
|
return parts.joined(separator: " · ")
|
||||||
|
}()
|
||||||
|
items.append(TimelineItem(
|
||||||
|
date: date, time: time,
|
||||||
|
icon: t.type == "plane" ? "airplane" : (t.type == "train" ? "tram.fill" : "car.fill"),
|
||||||
|
iconColor: .blue,
|
||||||
|
title: t.name, subtitle: subtitle, category: t.type
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
for l in detail.lodging {
|
||||||
|
let date = (l.checkIn ?? "").prefix(10)
|
||||||
|
let time = l.checkIn?.contains("T") == true ? String(l.checkIn!.suffix(from: l.checkIn!.index(l.checkIn!.startIndex, offsetBy: 11))) : ""
|
||||||
|
let checkout = l.checkOut != nil ? Trip.formatDisplay(String(l.checkOut!.prefix(10))) : ""
|
||||||
|
let subtitle = [l.location ?? "", checkout.isEmpty ? "" : "Check-out: \(checkout)"]
|
||||||
|
.filter { !$0.isEmpty }.joined(separator: " · ")
|
||||||
|
items.append(TimelineItem(
|
||||||
|
date: String(date), time: time,
|
||||||
|
icon: "bed.double.fill",
|
||||||
|
iconColor: Color.accentWarm,
|
||||||
|
title: l.name, subtitle: subtitle, category: nil
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
for loc in detail.locations {
|
||||||
|
let date = loc.visitDate ?? ""
|
||||||
|
let rawDate = date.prefix(10)
|
||||||
|
let time: String = {
|
||||||
|
if date.contains("T") {
|
||||||
|
return String(date.suffix(from: date.index(date.startIndex, offsetBy: 11)))
|
||||||
|
}
|
||||||
|
return loc.startTime ?? ""
|
||||||
|
}()
|
||||||
|
items.append(TimelineItem(
|
||||||
|
date: String(rawDate), time: time,
|
||||||
|
icon: "mappin.and.ellipse",
|
||||||
|
iconColor: .red,
|
||||||
|
title: loc.name, subtitle: loc.address ?? "", category: loc.category
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date then time
|
||||||
|
items.sort { a, b in
|
||||||
|
if a.date == b.date { return a.time < b.time }
|
||||||
|
return a.date < b.date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by date
|
||||||
|
var grouped: [(String, [TimelineItem])] = []
|
||||||
|
var currentDate = ""
|
||||||
|
var currentItems: [TimelineItem] = []
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
let d = item.date.isEmpty ? "No date" : item.date
|
||||||
|
if d != currentDate {
|
||||||
|
if !currentItems.isEmpty {
|
||||||
|
grouped.append((currentDate, currentItems))
|
||||||
|
}
|
||||||
|
currentDate = d
|
||||||
|
currentItems = [item]
|
||||||
|
} else {
|
||||||
|
currentItems.append(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !currentItems.isEmpty {
|
||||||
|
grouped.append((currentDate, currentItems))
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timeline View
|
||||||
|
|
||||||
|
private func timelineView(_ days: [(String, [TimelineItem])]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(Array(days.enumerated()), id: \.0) { _, day in
|
||||||
|
let (date, items) = day
|
||||||
|
|
||||||
|
// Day header
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(formatDayHeader(date))
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.fontWidth(.expanded)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.accentWarm.opacity(0.2))
|
||||||
|
.frame(height: 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 24)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
// Items for this day
|
||||||
|
ForEach(Array(items.enumerated()), id: \.0) { idx, item in
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
// Timeline line + icon
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Circle()
|
||||||
|
.fill(item.iconColor)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: item.icon)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx < items.count - 1 {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.textTertiary.opacity(0.3))
|
||||||
|
.frame(width: 2)
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
// Content card
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if !item.time.isEmpty {
|
||||||
|
Text(formatTime(item.time))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(item.title)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
|
||||||
|
if let cat = item.category, !cat.isEmpty {
|
||||||
|
Text(cat.uppercased())
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWidth(.condensed)
|
||||||
|
.foregroundStyle(Color.accentWarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !item.subtitle.isEmpty {
|
||||||
|
Text(item.subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.surfaceCard)
|
||||||
|
.clipShape(.rect(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stats Overlay
|
||||||
|
|
||||||
private var tripStatsOverlay: some View {
|
private var tripStatsOverlay: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@@ -128,17 +247,17 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
if let d = detail {
|
if let d = detail {
|
||||||
if !d.lodging.isEmpty {
|
if !d.lodging.isEmpty {
|
||||||
Label("^[\(d.lodging.count) STAYS](inflect: true)", systemImage: "bed.double")
|
Label("\(d.lodging.count) stays", systemImage: "bed.double")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.fontWidth(.condensed)
|
.fontWidth(.condensed)
|
||||||
}
|
}
|
||||||
if !d.transportations.isEmpty {
|
if !d.transportations.isEmpty {
|
||||||
Label("^[\(d.transportations.count) FLIGHTS](inflect: true)", systemImage: "airplane")
|
Label("\(d.transportations.count) flights", systemImage: "airplane")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.fontWidth(.condensed)
|
.fontWidth(.condensed)
|
||||||
}
|
}
|
||||||
if !d.locations.isEmpty {
|
if !d.locations.isEmpty {
|
||||||
Label("^[\(d.locations.count) PLACES](inflect: true)", systemImage: "mappin")
|
Label("\(d.locations.count) places", systemImage: "mappin")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.fontWidth(.condensed)
|
.fontWidth(.condensed)
|
||||||
}
|
}
|
||||||
@@ -152,7 +271,25 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Load Detail
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func loadDetail() async {
|
private func loadDetail() async {
|
||||||
do {
|
do {
|
||||||
@@ -160,143 +297,36 @@ struct TripDetailView: View {
|
|||||||
} catch {}
|
} catch {}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func formatDayHeader(_ date: String) -> String {
|
||||||
|
guard let d = Trip.parseDate(date) else { return date }
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "EEEE, MMM d"
|
||||||
|
return f.string(from: d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Section Container
|
private func formatTime(_ time: String) -> String {
|
||||||
|
// Handle "HH:mm" or "HH:mm:ss" format
|
||||||
|
let clean = time.prefix(5)
|
||||||
|
guard clean.count == 5, let hour = Int(clean.prefix(2)), let min = Int(clean.suffix(2)) else {
|
||||||
|
return String(time.prefix(5))
|
||||||
|
}
|
||||||
|
let ampm = hour >= 12 ? "PM" : "AM"
|
||||||
|
let h = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour)
|
||||||
|
return String(format: "%d:%02d %@", h, min, ampm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct TripSection<Content: View>: View {
|
// MARK: - Timeline Item
|
||||||
let title: String
|
|
||||||
|
private struct TimelineItem {
|
||||||
|
let date: String
|
||||||
|
let time: String
|
||||||
let icon: String
|
let icon: String
|
||||||
@ViewBuilder let content: () -> Content
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
var body: some View {
|
let subtitle: String
|
||||||
Section {
|
let category: String?
|
||||||
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)
|
// MARK: - Gradient Overlay (from Wishlist GradientView)
|
||||||
|
|||||||
Reference in New Issue
Block a user