feat: polished trip timeline — Apple-level detail view
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:
@@ -1,16 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Displays detailed information about a trip as a chronological timeline.
|
||||
/// All items (flights, lodging, places) sorted by date/time — same as the web dashboard.
|
||||
/// Polished Trip Detail with continuous timeline.
|
||||
/// Hero image → stats overlay → day-by-day timeline with type-specific cards.
|
||||
struct TripDetailView: View {
|
||||
let trip: Trip
|
||||
@State private var detail: TripDetail?
|
||||
@State private var isLoading = true
|
||||
@State private var appeared = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Hero image — 510pt, Wishlist pattern
|
||||
// Hero
|
||||
TripImageView(url: trip.imageURL, fallbackName: trip.name)
|
||||
.scaledToFill()
|
||||
.containerRelativeFrame(.horizontal)
|
||||
@@ -20,7 +21,7 @@ struct TripDetailView: View {
|
||||
tripStatsOverlay
|
||||
}
|
||||
|
||||
// Timeline content
|
||||
// Timeline
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -35,7 +36,7 @@ struct TripDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentMargins(.bottom, 30, for: .scrollContent)
|
||||
.contentMargins(.bottom, 50, for: .scrollContent)
|
||||
.scrollEdgeEffectStyle(.soft, for: .top)
|
||||
.background(Color.canvas)
|
||||
.toolbar {
|
||||
@@ -55,76 +56,75 @@ struct TripDetailView: View {
|
||||
.navigationTitle(trip.name)
|
||||
.toolbarRole(.editor)
|
||||
.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
|
||||
|
||||
private func buildTimeline(_ detail: TripDetail) -> [(String, [TimelineItem])] {
|
||||
private func buildTimeline(_ detail: TripDetail) -> [TimelineDay] {
|
||||
var items: [TimelineItem] = []
|
||||
let tripStart = Trip.parseDate(trip.startDate)
|
||||
|
||||
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
|
||||
date: date, time: time, type: .transport,
|
||||
title: t.name,
|
||||
subtitle: [t.fromLocation, t.toLocation].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " → "),
|
||||
detail1: t.flightNumber,
|
||||
detail2: t.type,
|
||||
iconName: transportIcon(t.type),
|
||||
iconColor: .blue
|
||||
))
|
||||
}
|
||||
|
||||
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 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: " · ")
|
||||
let nights: String? = {
|
||||
guard let ci = l.checkIn, let co = l.checkOut,
|
||||
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(
|
||||
date: String(date), time: time,
|
||||
icon: "bed.double.fill",
|
||||
iconColor: Color.accentWarm,
|
||||
title: l.name, subtitle: subtitle, category: nil
|
||||
date: date, time: time, type: .lodging,
|
||||
title: l.name,
|
||||
subtitle: l.location ?? "",
|
||||
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 {
|
||||
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 ?? ""
|
||||
}()
|
||||
let raw = loc.visitDate ?? ""
|
||||
let date = String(raw.prefix(10))
|
||||
let time: String = raw.contains("T") ? String(raw.suffix(from: raw.index(raw.startIndex, offsetBy: 11))) : (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
|
||||
date: date, time: time, type: .place,
|
||||
title: loc.name,
|
||||
subtitle: loc.address ?? "",
|
||||
detail1: loc.category,
|
||||
detail2: nil,
|
||||
iconName: "mappin.and.ellipse",
|
||||
iconColor: .red
|
||||
))
|
||||
}
|
||||
|
||||
// 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 days: [TimelineDay] = []
|
||||
var currentDate = ""
|
||||
var currentItems: [TimelineItem] = []
|
||||
|
||||
@@ -132,7 +132,8 @@ struct TripDetailView: View {
|
||||
let d = item.date.isEmpty ? "No date" : item.date
|
||||
if d != currentDate {
|
||||
if !currentItems.isEmpty {
|
||||
grouped.append((currentDate, currentItems))
|
||||
let dayNum = dayNumber(currentDate, from: tripStart)
|
||||
days.append(TimelineDay(date: currentDate, dayNumber: dayNum, items: currentItems))
|
||||
}
|
||||
currentDate = d
|
||||
currentItems = [item]
|
||||
@@ -141,93 +142,243 @@ struct TripDetailView: View {
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
private func timelineView(_ days: [(String, [TimelineItem])]) -> some View {
|
||||
private func timelineView(_ days: [TimelineDay]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(days.enumerated()), id: \.0) { _, day in
|
||||
let (date, items) = day
|
||||
ForEach(Array(days.enumerated()), id: \.0) { dayIdx, day in
|
||||
|
||||
// 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) {
|
||||
HStack(spacing: 12) {
|
||||
// Day number badge on the timeline
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(item.iconColor)
|
||||
.frame(width: 32, height: 32)
|
||||
.overlay {
|
||||
Image(systemName: item.icon)
|
||||
.font(.system(size: 13))
|
||||
.fill(Color.accentWarm)
|
||||
.frame(width: 40, height: 40)
|
||||
Text(day.dayNumber != nil ? "\(day.dayNumber!)" : "•")
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
if idx < items.count - 1 {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
if let num = day.dayNumber {
|
||||
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(.top, dayIdx == 0 ? 20 : 28)
|
||||
.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
|
||||
ForEach(Array(day.items.enumerated()), id: \.0) { idx, item in
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Timeline rail
|
||||
VStack(spacing: 0) {
|
||||
// Line above icon
|
||||
if dayIdx > 0 || idx > 0 {
|
||||
Rectangle()
|
||||
.fill(Color.textTertiary.opacity(0.3))
|
||||
.fill(Color.textTertiary.opacity(0.2))
|
||||
.frame(width: 2, height: 12)
|
||||
} else {
|
||||
Spacer().frame(height: 12)
|
||||
}
|
||||
|
||||
// Icon
|
||||
Circle()
|
||||
.fill(item.iconColor.opacity(0.15))
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay {
|
||||
Image(systemName: item.iconName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(item.iconColor)
|
||||
}
|
||||
|
||||
// Line below icon
|
||||
if idx < day.items.count - 1 || dayIdx < days.count - 1 {
|
||||
Rectangle()
|
||||
.fill(Color.textTertiary.opacity(0.2))
|
||||
.frame(width: 2)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(width: 32)
|
||||
.frame(width: 40)
|
||||
|
||||
// Content card
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if !item.time.isEmpty {
|
||||
Text(formatTime(item.time))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
// Card
|
||||
cardView(item)
|
||||
.padding(.leading, 8)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 6)
|
||||
.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)
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(item.title)
|
||||
.font(.body.weight(.medium))
|
||||
// MARK: - Type-Specific Cards
|
||||
|
||||
if let cat = item.category, !cat.isEmpty {
|
||||
Text(cat.uppercased())
|
||||
.font(.caption2)
|
||||
.fontWidth(.condensed)
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
@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)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
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(.rect(cornerRadius: 12))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
// MARK: - Stats Overlay
|
||||
@@ -291,13 +442,6 @@ struct TripDetailView: View {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func loadDetail() async {
|
||||
do {
|
||||
detail = try await TripsAPI().getTripDetail(id: trip.id)
|
||||
} catch {}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func formatDayHeader(_ date: String) -> String {
|
||||
guard let d = Trip.parseDate(date) else { return date }
|
||||
let f = DateFormatter()
|
||||
@@ -306,7 +450,6 @@ struct TripDetailView: View {
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -315,25 +458,53 @@ struct TripDetailView: View {
|
||||
let h = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour)
|
||||
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 {
|
||||
let date: String
|
||||
let time: String
|
||||
let icon: String
|
||||
let iconColor: Color
|
||||
let type: ItemType
|
||||
let title: 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 {
|
||||
let style: Style
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(style)
|
||||
|
||||
Reference in New Issue
Block a user