From fa3932c5978e6ac32e9dcca5d1b26411d3f6d539 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Sat, 4 Apr 2026 20:31:07 -0500 Subject: [PATCH] feat: Trip Detail as chronological timeline (matches web dashboard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Features/Trips/Models/TripModels.swift | 1 + .../Features/Trips/Views/TripDetailView.swift | 432 ++++++++++-------- 2 files changed, 232 insertions(+), 201 deletions(-) diff --git a/ios/Platform/Platform/Features/Trips/Models/TripModels.swift b/ios/Platform/Platform/Features/Trips/Models/TripModels.swift index fea6192..4d651b0 100644 --- a/ios/Platform/Platform/Features/Trips/Models/TripModels.swift +++ b/ios/Platform/Platform/Features/Trips/Models/TripModels.swift @@ -120,6 +120,7 @@ struct TripLocation: Codable, Identifiable { let address: String? let latitude: Double? let longitude: Double? + let startTime: String? } struct TripNote: Codable, Identifiable { diff --git a/ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift b/ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift index 2006469..b9a7f39 100644 --- a/ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift +++ b/ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift @@ -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) + } + + 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) + } } -// MARK: - Section Container +// MARK: - Timeline Item -struct TripSection: View { - let title: String +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)