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 latitude: Double?
|
||||
let longitude: Double?
|
||||
let startTime: String?
|
||||
}
|
||||
|
||||
struct TripNote: Codable, Identifiable {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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
|
||||
/// Displays detailed information about a trip as a chronological timeline.
|
||||
/// All items (flights, lodging, places) sorted by date/time — same as the web dashboard.
|
||||
struct TripDetailView: View {
|
||||
let trip: Trip
|
||||
@State private var detail: TripDetail?
|
||||
@@ -14,8 +9,8 @@ struct TripDetailView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Hero image — 510pt, same as Wishlist
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Hero image — 510pt, Wishlist pattern
|
||||
TripImageView(url: trip.imageURL, fallbackName: trip.name)
|
||||
.scaledToFill()
|
||||
.containerRelativeFrame(.horizontal)
|
||||
@@ -25,64 +20,17 @@ struct TripDetailView: View {
|
||||
tripStatsOverlay
|
||||
}
|
||||
|
||||
// Content sections
|
||||
// Timeline content
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
.padding(.vertical, 60)
|
||||
} 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)
|
||||
let timeline = buildTimeline(detail)
|
||||
if timeline.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
timelineView(timeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -128,17 +247,17 @@ struct TripDetailView: View {
|
||||
}
|
||||
if let d = detail {
|
||||
if !d.lodging.isEmpty {
|
||||
Label("^[\(d.lodging.count) STAYS](inflect: true)", systemImage: "bed.double")
|
||||
Label("\(d.lodging.count) stays", systemImage: "bed.double")
|
||||
.font(.footnote)
|
||||
.fontWidth(.condensed)
|
||||
}
|
||||
if !d.transportations.isEmpty {
|
||||
Label("^[\(d.transportations.count) FLIGHTS](inflect: true)", systemImage: "airplane")
|
||||
Label("\(d.transportations.count) flights", systemImage: "airplane")
|
||||
.font(.footnote)
|
||||
.fontWidth(.condensed)
|
||||
}
|
||||
if !d.locations.isEmpty {
|
||||
Label("^[\(d.locations.count) PLACES](inflect: true)", systemImage: "mappin")
|
||||
Label("\(d.locations.count) places", systemImage: "mappin")
|
||||
.font(.footnote)
|
||||
.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 {
|
||||
do {
|
||||
@@ -160,143 +297,36 @@ struct TripDetailView: View {
|
||||
} catch {}
|
||||
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 {
|
||||
let title: String
|
||||
// MARK: - Timeline Item
|
||||
|
||||
private struct TimelineItem {
|
||||
let date: String
|
||||
let time: 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))
|
||||
}
|
||||
let iconColor: Color
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let category: String?
|
||||
}
|
||||
|
||||
// MARK: - Gradient Overlay (from Wishlist GradientView)
|
||||
|
||||
Reference in New Issue
Block a user