diff --git a/ios/Platform/Platform.xcodeproj/project.pbxproj b/ios/Platform/Platform.xcodeproj/project.pbxproj index b7f6876..95b62ae 100644 --- a/ios/Platform/Platform.xcodeproj/project.pbxproj +++ b/ios/Platform/Platform.xcodeproj/project.pbxproj @@ -14,6 +14,15 @@ A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.swift */; }; A10050 /* AppearanceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10050 /* AppearanceManager.swift */; }; A10051 /* EditableDraftCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10051 /* EditableDraftCard.swift */; }; + A10060 /* TripModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10060 /* TripModels.swift */; }; + A10061 /* TripsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10061 /* TripsAPI.swift */; }; + A10062 /* TripsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10062 /* TripsViewModel.swift */; }; + A10063 /* TripsHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10063 /* TripsHomeView.swift */; }; + A10064 /* UpcomingTripsPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10064 /* UpcomingTripsPageView.swift */; }; + A10065 /* PastTripsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10065 /* PastTripsSection.swift */; }; + A10066 /* TripCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10066 /* TripCard.swift */; }; + A10067 /* TripImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10067 /* TripImageView.swift */; }; + A10068 /* TripPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10068 /* TripPlaceholderView.swift */; }; A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006 /* LoginView.swift */; }; A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; }; A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008 /* HomeViewModel.swift */; }; @@ -90,6 +99,15 @@ B10005 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; B10050 /* AppearanceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceManager.swift; sourceTree = ""; }; B10051 /* EditableDraftCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableDraftCard.swift; sourceTree = ""; }; + B10060 /* TripModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripModels.swift; sourceTree = ""; }; + B10061 /* TripsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripsAPI.swift; sourceTree = ""; }; + B10062 /* TripsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripsViewModel.swift; sourceTree = ""; }; + B10063 /* TripsHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripsHomeView.swift; sourceTree = ""; }; + B10064 /* UpcomingTripsPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpcomingTripsPageView.swift; sourceTree = ""; }; + B10065 /* PastTripsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PastTripsSection.swift; sourceTree = ""; }; + B10066 /* TripCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripCard.swift; sourceTree = ""; }; + B10067 /* TripImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripImageView.swift; sourceTree = ""; }; + B10068 /* TripPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripPlaceholderView.swift; sourceTree = ""; }; B10006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; B10007 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; @@ -201,6 +219,7 @@ F10014 /* Assistant */, F10021 /* Feedback */, F10030 /* Reader */, + F10050 /* Trips */, ); path = Features; sourceTree = ""; @@ -400,6 +419,54 @@ path = Views; sourceTree = ""; }; + F10050 /* Trips */ = { + isa = PBXGroup; + children = ( + F10051 /* Models */, + F10052 /* API */, + F10053 /* ViewModels */, + F10054 /* Views */, + ); + path = Trips; + sourceTree = ""; + }; + F10051 /* Models */ = { + isa = PBXGroup; + children = ( + B10060 /* TripModels.swift */, + ); + path = Models; + sourceTree = ""; + }; + F10052 /* API */ = { + isa = PBXGroup; + children = ( + B10061 /* TripsAPI.swift */, + ); + path = API; + sourceTree = ""; + }; + F10053 /* ViewModels */ = { + isa = PBXGroup; + children = ( + B10062 /* TripsViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + F10054 /* Views */ = { + isa = PBXGroup; + children = ( + B10063 /* TripsHomeView.swift */, + B10064 /* UpcomingTripsPageView.swift */, + B10065 /* PastTripsSection.swift */, + B10066 /* TripCard.swift */, + B10067 /* TripImageView.swift */, + B10068 /* TripPlaceholderView.swift */, + ); + path = Views; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -518,6 +585,15 @@ A10005 /* AuthManager.swift in Sources */, A10050 /* AppearanceManager.swift in Sources */, A10051 /* EditableDraftCard.swift in Sources */, + A10060 /* TripModels.swift in Sources */, + A10061 /* TripsAPI.swift in Sources */, + A10062 /* TripsViewModel.swift in Sources */, + A10063 /* TripsHomeView.swift in Sources */, + A10064 /* UpcomingTripsPageView.swift in Sources */, + A10065 /* PastTripsSection.swift in Sources */, + A10066 /* TripCard.swift in Sources */, + A10067 /* TripImageView.swift in Sources */, + A10068 /* TripPlaceholderView.swift in Sources */, A10006 /* LoginView.swift in Sources */, A10007 /* HomeView.swift in Sources */, A10008 /* HomeViewModel.swift in Sources */, diff --git a/ios/Platform/Platform/ContentView.swift b/ios/Platform/Platform/ContentView.swift index da0f7c4..f9884ed 100644 --- a/ios/Platform/Platform/ContentView.swift +++ b/ios/Platform/Platform/ContentView.swift @@ -81,6 +81,10 @@ struct MainTabView: View { } } + Tab("Trips", systemImage: "airplane", value: 4) { + TripsHomeView() + } + // Action button — separated circle on trailing side of tab bar // Home/Fitness: quick add food (+) // Reader: play/pause auto-scroll diff --git a/ios/Platform/Platform/Features/Trips/API/TripsAPI.swift b/ios/Platform/Platform/Features/Trips/API/TripsAPI.swift new file mode 100644 index 0000000..ba3c82d --- /dev/null +++ b/ios/Platform/Platform/Features/Trips/API/TripsAPI.swift @@ -0,0 +1,15 @@ +import Foundation + +struct TripsAPI { + private let api = APIClient.shared + private let basePath = "/api/trips" + + func getTrips() async throws -> [Trip] { + let response: TripsResponse = try await api.get("\(basePath)/trips") + return response.trips + } + + func getTrip(id: String) async throws -> Trip { + try await api.get("\(basePath)/trip/\(id)") + } +} diff --git a/ios/Platform/Platform/Features/Trips/Models/TripModels.swift b/ios/Platform/Platform/Features/Trips/Models/TripModels.swift new file mode 100644 index 0000000..848a371 --- /dev/null +++ b/ios/Platform/Platform/Features/Trips/Models/TripModels.swift @@ -0,0 +1,77 @@ +import Foundation + +struct Trip: Codable, Identifiable, Hashable { + let id: String + let name: String + let description: String? + let startDate: String + let endDate: String + let coverImage: String? + let createdAt: String? + + var imageURL: URL? { + guard let cover = coverImage, !cover.isEmpty else { return nil } + return URL(string: "\(Config.gatewayURL)/api/trips\(cover)") + } + + var dateRange: String { + let start = Self.formatDisplay(startDate) + let end = Self.formatDisplay(endDate) + return "\(start) – \(end)" + } + + var tripLength: String { + guard let s = Self.parseDate(startDate), + let e = Self.parseDate(endDate) else { return "" } + let days = Calendar.current.dateComponents([.day], from: s, to: e).day ?? 0 + return days == 1 ? "1 day" : "\(days) days" + } + + var isUpcoming: Bool { + guard let start = Self.parseDate(startDate) else { return false } + return start > Date() + } + + var isActive: Bool { + guard let start = Self.parseDate(startDate), + let end = Self.parseDate(endDate) else { return false } + let now = Date() + return start <= now && now <= end + } + + var isPast: Bool { + guard let end = Self.parseDate(endDate) else { return false } + return end < Date() + } + + var year: String { + String(startDate.prefix(4)) + } + + // MARK: - Date helpers + + private static let apiFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f + }() + + private static let displayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM d" + return f + }() + + static func parseDate(_ str: String) -> Date? { + apiFormatter.date(from: str) + } + + static func formatDisplay(_ str: String) -> String { + guard let date = parseDate(str) else { return str } + return displayFormatter.string(from: date) + } +} + +struct TripsResponse: Codable { + let trips: [Trip] +} diff --git a/ios/Platform/Platform/Features/Trips/ViewModels/TripsViewModel.swift b/ios/Platform/Platform/Features/Trips/ViewModels/TripsViewModel.swift new file mode 100644 index 0000000..8192006 --- /dev/null +++ b/ios/Platform/Platform/Features/Trips/ViewModels/TripsViewModel.swift @@ -0,0 +1,46 @@ +import Foundation + +@MainActor +@Observable +final class TripsViewModel { + var trips: [Trip] = [] + var isLoading = true + var error: String? + + private let api = TripsAPI() + private var hasLoaded = false + + var upcomingTrips: [Trip] { + trips.filter { $0.isUpcoming || $0.isActive } + .sorted { $0.startDate < $1.startDate } + } + + var pastTrips: [Trip] { + trips.filter { $0.isPast } + .sorted { $0.startDate > $1.startDate } + } + + func loadTrips() async { + guard !hasLoaded else { return } + hasLoaded = true + isLoading = true + error = nil + + do { + trips = try await api.getTrips() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func refresh() async { + isLoading = true + do { + trips = try await api.getTrips() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } +} diff --git a/ios/Platform/Platform/Features/Trips/Views/PastTripsSection.swift b/ios/Platform/Platform/Features/Trips/Views/PastTripsSection.swift new file mode 100644 index 0000000..d6c7dc7 --- /dev/null +++ b/ios/Platform/Platform/Features/Trips/Views/PastTripsSection.swift @@ -0,0 +1,48 @@ +import SwiftUI + +/// Displays past trips in a horizontal scroll with compact cards. +/// Adapted from Apple's Wishlist TripCollectionView. +struct PastTripsSection: View { + let trips: [Trip] + let namespace: Namespace.ID + + var body: some View { + if !trips.isEmpty { + Section { + ScrollView(.horizontal) { + HStack(spacing: 12) { + ForEach(trips) { trip in + NavigationLink { + TripPlaceholderView(trip: trip) + .navigationTransition( + .zoom(sourceID: trip.id, in: namespace)) + } label: { + TripCard(trip: trip, size: .compact) + .matchedTransitionSource(id: trip.id, in: namespace) + .contentShape(.rect) + } + .buttonStyle(.plain) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.viewAligned) + .scrollClipDisabled() + .scrollIndicators(.hidden, axes: .horizontal) + .padding(.bottom, 30) + } header: { + VStack(alignment: .leading, spacing: 0) { + Text("Past Trips") + .font(.title3) + .fontWidth(.expanded) + + Text("Your travel memories") + .font(.subheadline) + .foregroundColor(.secondary) + } + .lineLimit(2) + } + .padding(.horizontal, 16) + } + } +} diff --git a/ios/Platform/Platform/Features/Trips/Views/TripCard.swift b/ios/Platform/Platform/Features/Trips/Views/TripCard.swift new file mode 100644 index 0000000..b1a226c --- /dev/null +++ b/ios/Platform/Platform/Features/Trips/Views/TripCard.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// A card view displaying a trip's photo and metadata. +/// Adapted from Apple's Wishlist TripCard — supports compact and expanded sizes. +struct TripCard: View { + let trip: Trip + let size: Size + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + TripImageView(url: trip.imageURL, fallbackName: trip.name) + .scaledToFill() + .frame(width: size.width, height: size.height) + .clipShape(.rect(cornerRadius: 16)) + .overlay(alignment: .bottomLeading) { + if !trip.tripLength.isEmpty { + Text(trip.tripLength.uppercased()) + .font(.footnote) + .fontWidth(.condensed) + .foregroundStyle(.secondary) + .padding(4) + .background(.regularMaterial, in: .rect(cornerRadius: 8)) + .padding([.leading, .bottom], 8) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(trip.name) + .font(.body.weight(.medium)) + + Text(trip.dateRange) + .font(.subheadline) + .foregroundColor(.secondary) + } + .lineLimit(2) + } + .frame(width: size.width) + } +} + +// MARK: - Card sizes + +extension TripCard { + enum Size { + case compact + case expanded + } +} + +private extension TripCard.Size { + var width: CGFloat { + switch self { + case .compact: 180 + case .expanded: 325 + } + } + + var height: CGFloat { + switch self { + case .compact: 200 + case .expanded: 260 + } + } +} diff --git a/ios/Platform/Platform/Features/Trips/Views/TripImageView.swift b/ios/Platform/Platform/Features/Trips/Views/TripImageView.swift new file mode 100644 index 0000000..a65a99d --- /dev/null +++ b/ios/Platform/Platform/Features/Trips/Views/TripImageView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +/// Displays a trip image from a URL, with gradient placeholder for missing images. +/// Adapted from Apple's Wishlist TripImageView pattern. +struct TripImageView: View { + let url: URL? + var fallbackName: String = "" + + var body: some View { + if let url { + AsyncImage(url: url) { phase in + if let image = phase.image { + // Rectangle overlay pattern from Wishlist — constrains AsyncImage properly + Rectangle() + .fill(.background) + .overlay { + image.resizable() + .scaledToFill() + } + .clipped() + } else if phase.error != nil { + placeholderGradient + } else { + Rectangle() + .fill(Color.surfaceCard) + .overlay { + ProgressView() + .progressViewStyle(.circular) + } + } + } + } else { + placeholderGradient + } + } + + private var placeholderGradient: some View { + ZStack { + LinearGradient( + colors: [ + Color.accentWarm.opacity(0.6), + Color.accentWarm.opacity(0.3), + Color.emerald.opacity(0.4) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + if !fallbackName.isEmpty { + Text(fallbackName) + .font(.title3.weight(.semibold)) + .fontWidth(.expanded) + .foregroundStyle(.white) + .shadow(radius: 4) + } + } + } +} diff --git a/ios/Platform/Platform/Features/Trips/Views/TripPlaceholderView.swift b/ios/Platform/Platform/Features/Trips/Views/TripPlaceholderView.swift new file mode 100644 index 0000000..f251bc2 --- /dev/null +++ b/ios/Platform/Platform/Features/Trips/Views/TripPlaceholderView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// Placeholder trip detail — Phase 1 only. +/// Will be replaced with full itinerary/reservations/notes in Phase 2. +struct TripPlaceholderView: View { + let trip: Trip + + var body: some View { + ScrollView { + VStack(spacing: 24) { + // Hero image + TripImageView(url: trip.imageURL, fallbackName: trip.name) + .frame(height: 260) + .clipShape(.rect(cornerRadius: 20)) + .padding(.horizontal) + + // Trip info + VStack(spacing: 12) { + Text(trip.name) + .font(.largeTitle.weight(.bold)) + .fontWidth(.expanded) + + Text(trip.dateRange) + .font(.title3) + .foregroundStyle(.secondary) + + if !trip.tripLength.isEmpty { + Text(trip.tripLength) + .font(.subheadline) + .foregroundStyle(.tertiary) + } + } + + // Coming soon + VStack(spacing: 16) { + Image(systemName: "map.fill") + .font(.system(size: 40)) + .foregroundStyle(Color.accentWarm.opacity(0.3)) + + Text("Trip details coming soon") + .font(.headline) + .foregroundStyle(Color.textSecondary) + + Text("Itinerary, reservations, notes, and more") + .font(.subheadline) + .foregroundStyle(Color.textTertiary) + } + .padding(.top, 40) + } + .padding(.top, 20) + } + .background(Color.canvas) + .navigationTitle(trip.name) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/ios/Platform/Platform/Features/Trips/Views/TripsHomeView.swift b/ios/Platform/Platform/Features/Trips/Views/TripsHomeView.swift new file mode 100644 index 0000000..5684443 --- /dev/null +++ b/ios/Platform/Platform/Features/Trips/Views/TripsHomeView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +/// The main Trips home screen — inspired by Apple's Wishlist sample. +/// +/// Layout: +/// 1. Hero section: upcoming trips as full-width paged cards +/// 2. Secondary section: past trips in horizontal compact cards +/// +/// Adapted from WishlistView structure: NavigationStack + ScrollView + sections. +struct TripsHomeView: View { + @State private var vm = TripsViewModel() + + @Namespace private var namespace + + var body: some View { + NavigationStack { + Group { + if vm.isLoading && vm.trips.isEmpty { + LoadingView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + // Hero: upcoming trips (paged, full-width) + UpcomingTripsPageView(trips: vm.upcomingTrips) + .padding(.bottom, 20) + + // Secondary: past trips (horizontal scroll, compact cards) + PastTripsSection(trips: vm.pastTrips, namespace: namespace) + } + } + .contentMargins(.bottom, 30, for: .scrollContent) + .ignoresSafeArea(edges: .top) + .refreshable { + await vm.refresh() + } + } + } + .background(Color.canvas) + .toolbar { + // Custom expanded title — Wishlist pattern + ToolbarItem(placement: .title) { + HStack { + Text("Trips") + .font(.system(size: 34, weight: .medium)) + .fontWidth(.expanded) + .bold() + .fixedSize() + Spacer() + } + } + + // Plan Trip button — glass prominent style + ToolbarItem(placement: .topBarTrailing) { + Button { + // Phase 2: present plan trip sheet + } label: { + Image(systemName: "plus") + } + .buttonStyle(.glassProminent) + .tint(.accentColor) + } + } + .navigationTitle("Trips") + .toolbarTitleDisplayMode(.inline) + .toolbarRole(.editor) + } + .task { + await vm.loadTrips() + } + } +} diff --git a/ios/Platform/Platform/Features/Trips/Views/UpcomingTripsPageView.swift b/ios/Platform/Platform/Features/Trips/Views/UpcomingTripsPageView.swift new file mode 100644 index 0000000..a89748b --- /dev/null +++ b/ios/Platform/Platform/Features/Trips/Views/UpcomingTripsPageView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +/// Displays upcoming trips in a paged TabView — the hero section. +/// Adapted from Apple's Wishlist RecentTripsPageView. +struct UpcomingTripsPageView: View { + let trips: [Trip] + + @Namespace private var namespace + + var body: some View { + if trips.isEmpty { + // No upcoming trips — show a prompt + VStack(spacing: 16) { + Image(systemName: "airplane.departure") + .font(.system(size: 44)) + .foregroundStyle(Color.accentWarm.opacity(0.4)) + Text("No upcoming trips") + .font(.title3.weight(.medium)) + .foregroundStyle(Color.textPrimary) + Text("Tap + to plan your next adventure") + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + } else { + TabView { + ForEach(trips) { trip in + NavigationLink { + TripPlaceholderView(trip: trip) + .navigationTransition( + .zoom(sourceID: trip.id, in: namespace)) + } label: { + TripImageView(url: trip.imageURL, fallbackName: trip.name) + .overlay(alignment: .bottomLeading) { + VStack(alignment: .leading, spacing: 4) { + Text("UPCOMING") + .font(.subheadline) + .fontWeight(.bold) + .foregroundStyle(Color.emerald) + + Text(trip.name) + .font(.title) + .fontWidth(.expanded) + .fontWeight(.medium) + .foregroundStyle(.primary) + + Text(trip.dateRange) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.bottom, 54) + } + .matchedTransitionSource(id: trip.id, in: namespace) + } + .buttonStyle(.plain) + } + } + .tabViewStyle(.page) + .containerRelativeFrame([.horizontal, .vertical]) { length, axis in + if axis == .vertical { + return length / 1.3 + } else { + return length + } + } + } + } +}