feat: Trips home screen — inspired by Apple's Wishlist sample
All checks were successful
Security Checks / dockerfile-lint (push) Successful in 3s
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 4s

Mapped Wishlist → Trips:
- WishlistView → TripsHomeView (NavigationStack + ScrollView)
- RecentTripsPageView → UpcomingTripsPageView (paged TabView hero)
- TripCollectionView → PastTripsSection (horizontal scroll compact)
- TripCard → TripCard (.compact/.expanded sizes)
- TripImageView → TripImageView (Rectangle overlay + AsyncImage)
- ExpandedNavigationTitle → same pattern for "Trips" title
- AddTripView → Plan Trip button (.glassProminent, Phase 2)

Structure (9 files):
- TripModels.swift: Trip model with date helpers, image URL builder
- TripsAPI.swift: getTrips() via gateway /api/trips/trips
- TripsViewModel.swift: upcoming/past sorting, @Observable
- TripsHomeView.swift: main screen, Wishlist layout pattern
- UpcomingTripsPageView.swift: full-width paged hero cards
- PastTripsSection.swift: horizontal compact card scroll
- TripCard.swift: reusable card with compact/expanded sizes
- TripImageView.swift: AsyncImage with gradient placeholder
- TripPlaceholderView.swift: simple detail for Phase 1

New tab: Trips (airplane icon) in tab bar for all users.
Images served via gateway: /api/trips/images/{filename}

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 14:16:15 -05:00
parent 1cfb729cae
commit 4d6960c508
11 changed files with 586 additions and 0 deletions

View File

@@ -14,6 +14,15 @@
A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.swift */; }; A10005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10005 /* AuthManager.swift */; };
A10050 /* AppearanceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10050 /* AppearanceManager.swift */; }; A10050 /* AppearanceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10050 /* AppearanceManager.swift */; };
A10051 /* EditableDraftCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10051 /* EditableDraftCard.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 */; }; A10006 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10006 /* LoginView.swift */; };
A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; }; A10007 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10007 /* HomeView.swift */; };
A10008 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10008 /* HomeViewModel.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 = "<group>"; }; B10005 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
B10050 /* AppearanceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceManager.swift; sourceTree = "<group>"; }; B10050 /* AppearanceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceManager.swift; sourceTree = "<group>"; };
B10051 /* EditableDraftCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableDraftCard.swift; sourceTree = "<group>"; }; B10051 /* EditableDraftCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableDraftCard.swift; sourceTree = "<group>"; };
B10060 /* TripModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripModels.swift; sourceTree = "<group>"; };
B10061 /* TripsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripsAPI.swift; sourceTree = "<group>"; };
B10062 /* TripsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripsViewModel.swift; sourceTree = "<group>"; };
B10063 /* TripsHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripsHomeView.swift; sourceTree = "<group>"; };
B10064 /* UpcomingTripsPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpcomingTripsPageView.swift; sourceTree = "<group>"; };
B10065 /* PastTripsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PastTripsSection.swift; sourceTree = "<group>"; };
B10066 /* TripCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripCard.swift; sourceTree = "<group>"; };
B10067 /* TripImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripImageView.swift; sourceTree = "<group>"; };
B10068 /* TripPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TripPlaceholderView.swift; sourceTree = "<group>"; };
B10006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; }; B10006 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
B10007 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; }; B10007 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; }; B10008 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
@@ -201,6 +219,7 @@
F10014 /* Assistant */, F10014 /* Assistant */,
F10021 /* Feedback */, F10021 /* Feedback */,
F10030 /* Reader */, F10030 /* Reader */,
F10050 /* Trips */,
); );
path = Features; path = Features;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -400,6 +419,54 @@
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
F10050 /* Trips */ = {
isa = PBXGroup;
children = (
F10051 /* Models */,
F10052 /* API */,
F10053 /* ViewModels */,
F10054 /* Views */,
);
path = Trips;
sourceTree = "<group>";
};
F10051 /* Models */ = {
isa = PBXGroup;
children = (
B10060 /* TripModels.swift */,
);
path = Models;
sourceTree = "<group>";
};
F10052 /* API */ = {
isa = PBXGroup;
children = (
B10061 /* TripsAPI.swift */,
);
path = API;
sourceTree = "<group>";
};
F10053 /* ViewModels */ = {
isa = PBXGroup;
children = (
B10062 /* TripsViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
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 = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -518,6 +585,15 @@
A10005 /* AuthManager.swift in Sources */, A10005 /* AuthManager.swift in Sources */,
A10050 /* AppearanceManager.swift in Sources */, A10050 /* AppearanceManager.swift in Sources */,
A10051 /* EditableDraftCard.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 */, A10006 /* LoginView.swift in Sources */,
A10007 /* HomeView.swift in Sources */, A10007 /* HomeView.swift in Sources */,
A10008 /* HomeViewModel.swift in Sources */, A10008 /* HomeViewModel.swift in Sources */,

View File

@@ -81,6 +81,10 @@ struct MainTabView: View {
} }
} }
Tab("Trips", systemImage: "airplane", value: 4) {
TripsHomeView()
}
// Action button separated circle on trailing side of tab bar // Action button separated circle on trailing side of tab bar
// Home/Fitness: quick add food (+) // Home/Fitness: quick add food (+)
// Reader: play/pause auto-scroll // Reader: play/pause auto-scroll

View File

@@ -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)")
}
}

View File

@@ -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]
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
}
}
}
}
}