feat: Trips home screen — inspired by Apple's Wishlist sample
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:
@@ -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
|
||||
|
||||
15
ios/Platform/Platform/Features/Trips/API/TripsAPI.swift
Normal file
15
ios/Platform/Platform/Features/Trips/API/TripsAPI.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
77
ios/Platform/Platform/Features/Trips/Models/TripModels.swift
Normal file
77
ios/Platform/Platform/Features/Trips/Models/TripModels.swift
Normal 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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
64
ios/Platform/Platform/Features/Trips/Views/TripCard.swift
Normal file
64
ios/Platform/Platform/Features/Trips/Views/TripCard.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user