diff --git a/ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift b/ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift index b9a7f39..20ee863 100644 --- a/ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift +++ b/ios/Platform/Platform/Features/Trips/Views/TripDetailView.swift @@ -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) + HStack(spacing: 12) { + // Day number badge on the timeline + ZStack { + Circle() + .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() - .fill(Color.accentWarm.opacity(0.2)) - .frame(height: 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, 24) - .padding(.bottom, 8) + .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 for this day - ForEach(Array(items.enumerated()), id: \.0) { idx, item in - HStack(alignment: .top, spacing: 12) { - // Timeline line + icon + // 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.2)) + .frame(width: 2, height: 12) + } else { + Spacer().frame(height: 12) + } + + // Icon Circle() - .fill(item.iconColor) - .frame(width: 32, height: 32) + .fill(item.iconColor.opacity(0.15)) + .frame(width: 28, height: 28) .overlay { - Image(systemName: item.icon) - .font(.system(size: 13)) - .foregroundStyle(.white) + Image(systemName: item.iconName) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(item.iconColor) } - if idx < items.count - 1 { + // Line below icon + if idx < day.items.count - 1 || dayIdx < days.count - 1 { Rectangle() - .fill(Color.textTertiary.opacity(0.3)) + .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) - } - - 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)) + // 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(.horizontal, 16) - .padding(.bottom, 4) + .padding(.leading, 16) } } } - .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 @@ -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: View { let style: Style - var body: some View { Rectangle() .fill(style)