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:
@@ -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 */,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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