feat: polished trip timeline — Apple-level detail view
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s

Continuous timeline rail:
- Vertical line runs full length connecting all days
- Colored icon nodes on the rail (tinted circle, not solid)
- Line connects across day boundaries (not just within days)

Day headers:
- Numbered badge on the timeline (Day 1, Day 2...)
- Date as "Tuesday, Jan 20" with expanded font
- Staggered entrance animation per day

Type-specific cards:
- ✈️ Flights: glass material card, route bar (FROM → TO),
  flight number capsule badge, transport type icon
- 🏨 Hotels: warm tinted card, night count badge capsule,
  location label, check-out info
- 📍 Places: compact card, category capsule badge (red),
  time in secondary text

Visual hierarchy:
- Flights/hotels: .regularMaterial background (glass, prominent)
- Places: surfaceCard background (compact, secondary)
- Staggered entrance: cards fade + slide up with delay

Polish:
- Consistent 14pt padding on major cards
- 10pt padding on compact place cards
- Icon colors: blue (transport), warm (lodging), red (places)
- Capsule badges for metadata (nights, category, flight #)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-05 07:23:22 -05:00
parent 58dd589d5a
commit 715175786b

View File

@@ -1,16 +1,17 @@
import SwiftUI import SwiftUI
/// Displays detailed information about a trip as a chronological timeline. /// Polished Trip Detail with continuous timeline.
/// All items (flights, lodging, places) sorted by date/time same as the web dashboard. /// Hero image stats overlay day-by-day timeline with type-specific cards.
struct TripDetailView: View { struct TripDetailView: View {
let trip: Trip let trip: Trip
@State private var detail: TripDetail? @State private var detail: TripDetail?
@State private var isLoading = true @State private var isLoading = true
@State private var appeared = false
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Hero image 510pt, Wishlist pattern // Hero
TripImageView(url: trip.imageURL, fallbackName: trip.name) TripImageView(url: trip.imageURL, fallbackName: trip.name)
.scaledToFill() .scaledToFill()
.containerRelativeFrame(.horizontal) .containerRelativeFrame(.horizontal)
@@ -20,7 +21,7 @@ struct TripDetailView: View {
tripStatsOverlay tripStatsOverlay
} }
// Timeline content // Timeline
if isLoading { if isLoading {
ProgressView() ProgressView()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -35,7 +36,7 @@ struct TripDetailView: View {
} }
} }
} }
.contentMargins(.bottom, 30, for: .scrollContent) .contentMargins(.bottom, 50, for: .scrollContent)
.scrollEdgeEffectStyle(.soft, for: .top) .scrollEdgeEffectStyle(.soft, for: .top)
.background(Color.canvas) .background(Color.canvas)
.toolbar { .toolbar {
@@ -55,76 +56,75 @@ struct TripDetailView: View {
.navigationTitle(trip.name) .navigationTitle(trip.name)
.toolbarRole(.editor) .toolbarRole(.editor)
.task { .task {
await loadDetail() do { detail = try await TripsAPI().getTripDetail(id: trip.id) } catch {}
isLoading = false
withAnimation(.easeOut(duration: 0.6).delay(0.1)) { appeared = true }
} }
} }
// MARK: - Timeline Builder // MARK: - Timeline Builder
private func buildTimeline(_ detail: TripDetail) -> [(String, [TimelineItem])] { private func buildTimeline(_ detail: TripDetail) -> [TimelineDay] {
var items: [TimelineItem] = [] var items: [TimelineItem] = []
let tripStart = Trip.parseDate(trip.startDate)
for t in detail.transportations { for t in detail.transportations {
let date = t.date ?? "" let date = t.date ?? ""
let time = t.startTime ?? "" 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( items.append(TimelineItem(
date: date, time: time, date: date, time: time, type: .transport,
icon: t.type == "plane" ? "airplane" : (t.type == "train" ? "tram.fill" : "car.fill"), title: t.name,
iconColor: .blue, subtitle: [t.fromLocation, t.toLocation].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ""),
title: t.name, subtitle: subtitle, category: t.type detail1: t.flightNumber,
detail2: t.type,
iconName: transportIcon(t.type),
iconColor: .blue
)) ))
} }
for l in detail.lodging { for l in detail.lodging {
let date = (l.checkIn ?? "").prefix(10) let date = String((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 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 nights: String? = {
let subtitle = [l.location ?? "", checkout.isEmpty ? "" : "Check-out: \(checkout)"] guard let ci = l.checkIn, let co = l.checkOut,
.filter { !$0.isEmpty }.joined(separator: " · ") let d1 = Trip.parseDate(String(ci.prefix(10))),
let d2 = Trip.parseDate(String(co.prefix(10))) else { return nil }
let n = Calendar.current.dateComponents([.day], from: d1, to: d2).day ?? 0
return n > 0 ? "\(n) night\(n == 1 ? "" : "s")" : nil
}()
items.append(TimelineItem( items.append(TimelineItem(
date: String(date), time: time, date: date, time: time, type: .lodging,
icon: "bed.double.fill", title: l.name,
iconColor: Color.accentWarm, subtitle: l.location ?? "",
title: l.name, subtitle: subtitle, category: nil detail1: nights,
detail2: l.checkOut != nil ? "Check-out: \(Trip.formatDisplay(String(l.checkOut!.prefix(10))))" : nil,
iconName: "bed.double.fill",
iconColor: Color.accentWarm
)) ))
} }
for loc in detail.locations { for loc in detail.locations {
let date = loc.visitDate ?? "" let raw = loc.visitDate ?? ""
let rawDate = date.prefix(10) let date = String(raw.prefix(10))
let time: String = { let time: String = raw.contains("T") ? String(raw.suffix(from: raw.index(raw.startIndex, offsetBy: 11))) : (loc.startTime ?? "")
if date.contains("T") {
return String(date.suffix(from: date.index(date.startIndex, offsetBy: 11)))
}
return loc.startTime ?? ""
}()
items.append(TimelineItem( items.append(TimelineItem(
date: String(rawDate), time: time, date: date, time: time, type: .place,
icon: "mappin.and.ellipse", title: loc.name,
iconColor: .red, subtitle: loc.address ?? "",
title: loc.name, subtitle: loc.address ?? "", category: loc.category detail1: loc.category,
detail2: nil,
iconName: "mappin.and.ellipse",
iconColor: .red
)) ))
} }
// Sort by date then time
items.sort { a, b in items.sort { a, b in
if a.date == b.date { return a.time < b.time } if a.date == b.date { return a.time < b.time }
return a.date < b.date return a.date < b.date
} }
// Group by date // Group by date
var grouped: [(String, [TimelineItem])] = [] var days: [TimelineDay] = []
var currentDate = "" var currentDate = ""
var currentItems: [TimelineItem] = [] var currentItems: [TimelineItem] = []
@@ -132,7 +132,8 @@ struct TripDetailView: View {
let d = item.date.isEmpty ? "No date" : item.date let d = item.date.isEmpty ? "No date" : item.date
if d != currentDate { if d != currentDate {
if !currentItems.isEmpty { if !currentItems.isEmpty {
grouped.append((currentDate, currentItems)) let dayNum = dayNumber(currentDate, from: tripStart)
days.append(TimelineDay(date: currentDate, dayNumber: dayNum, items: currentItems))
} }
currentDate = d currentDate = d
currentItems = [item] currentItems = [item]
@@ -141,93 +142,243 @@ struct TripDetailView: View {
} }
} }
if !currentItems.isEmpty { if !currentItems.isEmpty {
grouped.append((currentDate, currentItems)) let dayNum = dayNumber(currentDate, from: tripStart)
days.append(TimelineDay(date: currentDate, dayNumber: dayNum, items: currentItems))
} }
return grouped return days
} }
// MARK: - Timeline View // MARK: - Timeline View
private func timelineView(_ days: [(String, [TimelineItem])]) -> some View { private func timelineView(_ days: [TimelineDay]) -> some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
ForEach(Array(days.enumerated()), id: \.0) { _, day in ForEach(Array(days.enumerated()), id: \.0) { dayIdx, day in
let (date, items) = day
// Day header // Day header
HStack(spacing: 8) { HStack(spacing: 12) {
Text(formatDayHeader(date)) // Day number badge on the timeline
.font(.subheadline.weight(.bold)) ZStack {
.fontWidth(.expanded) Circle()
.foregroundStyle(Color.accentWarm) .fill(Color.accentWarm)
.frame(width: 40, height: 40)
Text(day.dayNumber != nil ? "\(day.dayNumber!)" : "")
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
Rectangle() VStack(alignment: .leading, spacing: 1) {
.fill(Color.accentWarm.opacity(0.2)) if let num = day.dayNumber {
.frame(height: 1) Text("Day \(num)")
.font(.caption.weight(.bold))
.fontWidth(.expanded)
.foregroundStyle(Color.accentWarm)
}
Text(formatDayHeader(day.date))
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.textPrimary)
}
Spacer()
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 24) .padding(.top, dayIdx == 0 ? 20 : 28)
.padding(.bottom, 8) .padding(.bottom, 12)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 10)
.animation(.easeOut(duration: 0.4).delay(Double(dayIdx) * 0.05), value: appeared)
// Items for this day // Items
ForEach(Array(items.enumerated()), id: \.0) { idx, item in ForEach(Array(day.items.enumerated()), id: \.0) { idx, item in
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 0) {
// Timeline line + icon // Timeline rail
VStack(spacing: 0) { VStack(spacing: 0) {
// Line above icon
if dayIdx > 0 || idx > 0 {
Rectangle()
.fill(Color.textTertiary.opacity(0.2))
.frame(width: 2, height: 12)
} else {
Spacer().frame(height: 12)
}
// Icon
Circle() Circle()
.fill(item.iconColor) .fill(item.iconColor.opacity(0.15))
.frame(width: 32, height: 32) .frame(width: 28, height: 28)
.overlay { .overlay {
Image(systemName: item.icon) Image(systemName: item.iconName)
.font(.system(size: 13)) .font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(item.iconColor)
} }
if idx < items.count - 1 { // Line below icon
if idx < day.items.count - 1 || dayIdx < days.count - 1 {
Rectangle() Rectangle()
.fill(Color.textTertiary.opacity(0.3)) .fill(Color.textTertiary.opacity(0.2))
.frame(width: 2) .frame(width: 2)
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
} }
} }
.frame(width: 32) .frame(width: 40)
// Content card // Card
VStack(alignment: .leading, spacing: 4) { cardView(item)
if !item.time.isEmpty { .padding(.leading, 8)
Text(formatTime(item.time)) .padding(.trailing, 16)
.font(.caption) .padding(.bottom, 6)
.foregroundStyle(.secondary) .opacity(appeared ? 1 : 0)
} .offset(y: appeared ? 0 : 15)
.animation(.easeOut(duration: 0.4).delay(Double(dayIdx) * 0.05 + Double(idx) * 0.03), value: appeared)
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(.leading, 16)
.padding(.bottom, 4)
} }
} }
} }
.padding(.top, 8) }
// MARK: - Type-Specific Cards
@ViewBuilder
private func cardView(_ item: TimelineItem) -> some View {
switch item.type {
case .transport:
transportCard(item)
case .lodging:
lodgingCard(item)
case .place:
placeCard(item)
}
}
private func transportCard(_ item: TimelineItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 2) {
if !item.time.isEmpty {
Text(formatTime(item.time))
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
Text(item.title)
.font(.subheadline.weight(.semibold))
}
Spacer()
Image(systemName: item.iconName)
.font(.title3)
.foregroundStyle(.blue.opacity(0.6))
}
if !item.subtitle.isEmpty {
// Route bar
HStack(spacing: 8) {
let parts = item.subtitle.components(separatedBy: "")
if parts.count == 2 {
Text(parts[0])
.font(.caption.weight(.medium))
Image(systemName: "arrow.right")
.font(.system(size: 9))
.foregroundStyle(.tertiary)
Text(parts[1])
.font(.caption.weight(.medium))
} else {
Text(item.subtitle)
.font(.caption)
}
}
.foregroundStyle(.secondary)
}
if let flight = item.detail1, !flight.isEmpty {
Text(flight)
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.08))
.clipShape(Capsule())
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
}
private func lodgingCard(_ item: TimelineItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 2) {
if !item.time.isEmpty {
Text(formatTime(item.time))
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
Text(item.title)
.font(.subheadline.weight(.semibold))
}
Spacer()
if let nights = item.detail1, !nights.isEmpty {
Text(nights)
.font(.caption2.weight(.bold))
.foregroundStyle(Color.accentWarm)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.accentWarm.opacity(0.1))
.clipShape(Capsule())
}
}
if !item.subtitle.isEmpty {
Label(item.subtitle, systemImage: "location")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let checkout = item.detail2, !checkout.isEmpty {
Text(checkout)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentWarm.opacity(0.04))
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
}
private func placeCard(_ item: TimelineItem) -> some View {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 3) {
Text(item.title)
.font(.subheadline.weight(.medium))
.lineLimit(2)
if !item.time.isEmpty {
Text(formatTime(item.time))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
if let category = item.detail1, !category.isEmpty {
Text(category.uppercased())
.font(.system(size: 9, weight: .bold))
.fontWidth(.condensed)
.foregroundStyle(Color.red.opacity(0.7))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.red.opacity(0.08))
.clipShape(Capsule())
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 10))
} }
// MARK: - Stats Overlay // MARK: - Stats Overlay
@@ -291,13 +442,6 @@ struct TripDetailView: View {
// MARK: - Helpers // MARK: - Helpers
private func loadDetail() async {
do {
detail = try await TripsAPI().getTripDetail(id: trip.id)
} catch {}
isLoading = false
}
private func formatDayHeader(_ date: String) -> String { private func formatDayHeader(_ date: String) -> String {
guard let d = Trip.parseDate(date) else { return date } guard let d = Trip.parseDate(date) else { return date }
let f = DateFormatter() let f = DateFormatter()
@@ -306,7 +450,6 @@ struct TripDetailView: View {
} }
private func formatTime(_ time: String) -> String { private func formatTime(_ time: String) -> String {
// Handle "HH:mm" or "HH:mm:ss" format
let clean = time.prefix(5) let clean = time.prefix(5)
guard clean.count == 5, let hour = Int(clean.prefix(2)), let min = Int(clean.suffix(2)) else { guard clean.count == 5, let hour = Int(clean.prefix(2)), let min = Int(clean.suffix(2)) else {
return String(time.prefix(5)) return String(time.prefix(5))
@@ -315,25 +458,53 @@ struct TripDetailView: View {
let h = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour) let h = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour)
return String(format: "%d:%02d %@", h, min, ampm) return String(format: "%d:%02d %@", h, min, ampm)
} }
private func dayNumber(_ dateStr: String, from start: Date?) -> Int? {
guard let start, let date = Trip.parseDate(dateStr) else { return nil }
let days = Calendar.current.dateComponents([.day], from: start, to: date).day ?? 0
return days + 1
}
private func transportIcon(_ type: String?) -> String {
switch type {
case "plane": return "airplane"
case "train": return "tram.fill"
case "car", "rental": return "car.fill"
case "bus": return "bus.fill"
case "ferry": return "ferry.fill"
default: return "arrow.triangle.swap"
}
}
} }
// MARK: - Timeline Item // MARK: - Data Models
private struct TimelineDay {
let date: String
let dayNumber: Int?
let items: [TimelineItem]
}
private struct TimelineItem { private struct TimelineItem {
let date: String let date: String
let time: String let time: String
let icon: String let type: ItemType
let iconColor: Color
let title: String let title: String
let subtitle: String let subtitle: String
let category: String? let detail1: String?
let detail2: String?
let iconName: String
let iconColor: Color
enum ItemType {
case transport, lodging, place
}
} }
// MARK: - Gradient Overlay (from Wishlist GradientView) // MARK: - Gradient Overlay
struct GradientOverlay<Style: ShapeStyle>: View { struct GradientOverlay<Style: ShapeStyle>: View {
let style: Style let style: Style
var body: some View { var body: some View {
Rectangle() Rectangle()
.fill(style) .fill(style)