- Add local canonicalization pipeline (stadiums, teams, games) that generates deterministic canonical IDs before CloudKit upload - Fix CanonicalSyncService to use deterministic UUIDs from canonical IDs instead of random UUIDs from CloudKit records - Add SyncStadium/SyncTeam/SyncGame types to CloudKitService that preserve canonical ID relationships during sync - Add canonical ID field keys to CKModels for reading from CloudKit records - Bundle canonical JSON files (stadiums_canonical, teams_canonical, games_canonical, stadium_aliases) for consistent bootstrap data - Update BootstrapService to prefer canonical format files over legacy format This ensures all entities use consistent deterministic UUIDs derived from their canonical IDs, preventing duplicate records when syncing CloudKit data with bootstrapped local data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
148 lines
5.0 KiB
Swift
148 lines
5.0 KiB
Swift
//
|
|
// SuggestedTripCard.swift
|
|
// SportsTime
|
|
//
|
|
// Card component for displaying a suggested trip in the carousel.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct SuggestedTripCard: View {
|
|
let suggestedTrip: SuggestedTrip
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
|
// Header: Region badge + Sport icons
|
|
HStack {
|
|
// Region badge
|
|
Text(suggestedTrip.region.shortName)
|
|
.font(.system(size: Theme.FontSize.micro, weight: .semibold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, Theme.Spacing.xs)
|
|
.padding(.vertical, 4)
|
|
.background(regionColor)
|
|
.clipShape(Capsule())
|
|
|
|
Spacer()
|
|
|
|
// Sport icons
|
|
HStack(spacing: 4) {
|
|
ForEach(suggestedTrip.displaySports, id: \.self) { sport in
|
|
Image(systemName: sport.iconName)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(sport.themeColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Route preview (vertical)
|
|
routePreview
|
|
|
|
Spacer()
|
|
|
|
// Stats row - inline compact display
|
|
HStack(spacing: 6) {
|
|
Label {
|
|
Text(suggestedTrip.trip.totalGames == 1 ? "1 game" : "\(suggestedTrip.trip.totalGames) games")
|
|
} icon: {
|
|
Image(systemName: "sportscourt")
|
|
}
|
|
|
|
Text("•")
|
|
.foregroundStyle(Theme.textMuted(colorScheme).opacity(0.5))
|
|
|
|
Label {
|
|
Text(suggestedTrip.trip.stops.count == 1 ? "1 city" : "\(suggestedTrip.trip.stops.count) cities")
|
|
} icon: {
|
|
Image(systemName: "mappin")
|
|
}
|
|
}
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
|
|
// Date range
|
|
Text(suggestedTrip.trip.formattedDateRange)
|
|
.font(.system(size: Theme.FontSize.micro))
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.frame(width: 200, height: 160)
|
|
.background(Theme.cardBackground(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
|
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
|
}
|
|
.shadow(color: Theme.cardShadow(colorScheme), radius: 8, y: 4)
|
|
}
|
|
|
|
private var routePreview: some View {
|
|
let cities = suggestedTrip.trip.stops.map { $0.city }
|
|
let displayCities: [String]
|
|
|
|
if cities.count <= 3 {
|
|
displayCities = cities
|
|
} else {
|
|
displayCities = [cities.first ?? "", "...", cities.last ?? ""]
|
|
}
|
|
|
|
return VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(Array(displayCities.enumerated()), id: \.offset) { index, city in
|
|
if index > 0 {
|
|
// Connector
|
|
HStack(spacing: 4) {
|
|
Text("|")
|
|
.font(.system(size: 10))
|
|
Image(systemName: "chevron.down")
|
|
.font(.system(size: 8))
|
|
}
|
|
.foregroundStyle(Theme.warmOrange.opacity(0.6))
|
|
.padding(.leading, 4)
|
|
}
|
|
|
|
Text(city)
|
|
.font(.system(size: Theme.FontSize.caption, weight: index == 0 ? .semibold : .regular))
|
|
.foregroundStyle(index == 0 ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var regionColor: Color {
|
|
switch suggestedTrip.region {
|
|
case .east: return .blue
|
|
case .central: return .green
|
|
case .west: return .orange
|
|
case .crossCountry: return .purple
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#Preview {
|
|
let trip = Trip(
|
|
name: "Test Trip",
|
|
preferences: TripPreferences(),
|
|
stops: [
|
|
TripStop(id: UUID(), stopNumber: 1, city: "New York", state: "NY", coordinate: nil, arrivalDate: Date(), departureDate: Date(), games: [], isRestDay: false),
|
|
TripStop(id: UUID(),stopNumber: 2, city: "Boston", state: "MA", coordinate: nil, arrivalDate: Date(), departureDate: Date(), games: [], isRestDay: false),
|
|
TripStop(id: UUID(),stopNumber: 3, city: "Philadelphia", state: "PA", coordinate: nil, arrivalDate: Date(), departureDate: Date(), games: [], isRestDay: false)
|
|
],
|
|
totalGames: 5
|
|
)
|
|
|
|
let suggestedTrip = SuggestedTrip(
|
|
id: UUID(),
|
|
region: .east,
|
|
isSingleSport: false,
|
|
trip: trip,
|
|
richGames: [:],
|
|
sports: [.mlb, .nba]
|
|
)
|
|
|
|
SuggestedTripCard(suggestedTrip: suggestedTrip)
|
|
.padding()
|
|
}
|