From 77001045973e681b069d9ae50b9a60a74e074dbc Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 17 Jan 2026 21:28:11 -0600 Subject: [PATCH] feat(itinerary): add row views for itinerary items Add specialized row components for the new unified itinerary system: - DayHeaderRow: Day number, date display, and add item button - GameItemRow: Prominent card with sport color bar for games - TravelItemRow: Gold-styled travel segments with drag handle - CustomItemRow: Minimal custom items with icon, title, and optional time All views follow existing Theme patterns and use SportColorBar for consistency. Co-Authored-By: Claude Opus 4.5 --- .../Views/ItineraryRows/CustomItemRow.swift | 81 ++++++++++++++++ .../Views/ItineraryRows/DayHeaderRow.swift | 54 +++++++++++ .../Views/ItineraryRows/GameItemRow.swift | 68 ++++++++++++++ .../Views/ItineraryRows/TravelItemRow.swift | 94 +++++++++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 SportsTime/Features/Trip/Views/ItineraryRows/CustomItemRow.swift create mode 100644 SportsTime/Features/Trip/Views/ItineraryRows/DayHeaderRow.swift create mode 100644 SportsTime/Features/Trip/Views/ItineraryRows/GameItemRow.swift create mode 100644 SportsTime/Features/Trip/Views/ItineraryRows/TravelItemRow.swift diff --git a/SportsTime/Features/Trip/Views/ItineraryRows/CustomItemRow.swift b/SportsTime/Features/Trip/Views/ItineraryRows/CustomItemRow.swift new file mode 100644 index 0000000..25e6e11 --- /dev/null +++ b/SportsTime/Features/Trip/Views/ItineraryRows/CustomItemRow.swift @@ -0,0 +1,81 @@ +// +// CustomItemRow.swift +// SportsTime +// +// Row view for displaying custom user-added items in the itinerary. +// Minimal styling with drag handle for reordering. +// + +import SwiftUI + +struct CustomItemRow: View { + let item: ItineraryItem + let onTap: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + private var customInfo: CustomInfo? { + item.customInfo + } + + var body: some View { + Button(action: onTap) { + HStack(spacing: Theme.Spacing.md) { + // Drag handle + Image(systemName: "line.3.horizontal") + .font(.title3) + .foregroundStyle(Theme.textMuted(colorScheme)) + + // Icon + if let info = customInfo { + Text(info.icon) + .font(.title3) + } + + // Title + if let info = customInfo { + Text(info.title) + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + Spacer() + + // Time (if set) + if let time = customInfo?.time { + Text(time.formatted(.dateTime.hour().minute())) + .font(.caption) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + } + .padding(.vertical, Theme.Spacing.sm) + .padding(.horizontal, Theme.Spacing.md) + } + .buttonStyle(.plain) + } +} + +#Preview { + let customInfo = CustomInfo( + title: "Dinner at Pizzeria", + icon: "\u{1F355}", + time: Date() + ) + let item = ItineraryItem( + tripId: UUID(), + day: 1, + sortOrder: 2.0, + kind: .custom(customInfo) + ) + + VStack(spacing: 16) { + CustomItemRow(item: item, onTap: {}) + + // Without time + let noTimeInfo = CustomInfo(title: "Visit Museum", icon: "\u{1F3DB}", time: nil) + let noTimeItem = ItineraryItem(tripId: UUID(), day: 1, sortOrder: 3.0, kind: .custom(noTimeInfo)) + CustomItemRow(item: noTimeItem, onTap: {}) + } + .padding() + .background(Color.gray.opacity(0.1)) +} diff --git a/SportsTime/Features/Trip/Views/ItineraryRows/DayHeaderRow.swift b/SportsTime/Features/Trip/Views/ItineraryRows/DayHeaderRow.swift new file mode 100644 index 0000000..7b04d6a --- /dev/null +++ b/SportsTime/Features/Trip/Views/ItineraryRows/DayHeaderRow.swift @@ -0,0 +1,54 @@ +// +// DayHeaderRow.swift +// SportsTime +// +// Header row for a day in the itinerary with day number, date, and add button. +// + +import SwiftUI + +struct DayHeaderRow: View { + let dayNumber: Int + let date: Date + let onAddTapped: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + private var formattedDate: String { + date.formatted(.dateTime.weekday(.wide).month().day()) + } + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Day \(dayNumber)") + .font(.title3) + .fontWeight(.semibold) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(formattedDate) + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + Spacer() + + Button(action: onAddTapped) { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundStyle(Theme.warmOrange) + } + } + .padding(.vertical, Theme.Spacing.sm) + .padding(.horizontal, Theme.Spacing.md) + } +} + +#Preview { + VStack { + DayHeaderRow(dayNumber: 1, date: Date(), onAddTapped: {}) + DayHeaderRow(dayNumber: 2, date: Date().addingTimeInterval(86400), onAddTapped: {}) + } + .padding() + .background(Color.gray.opacity(0.1)) +} diff --git a/SportsTime/Features/Trip/Views/ItineraryRows/GameItemRow.swift b/SportsTime/Features/Trip/Views/ItineraryRows/GameItemRow.swift new file mode 100644 index 0000000..c36bc31 --- /dev/null +++ b/SportsTime/Features/Trip/Views/ItineraryRows/GameItemRow.swift @@ -0,0 +1,68 @@ +// +// GameItemRow.swift +// SportsTime +// +// Row view for displaying a game in the itinerary. Prominent card styling with sport color accent. +// Games are not reorderable so no drag handle is shown. +// + +import SwiftUI + +struct GameItemRow: View { + let game: RichGame + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: Theme.Spacing.md) { + // Sport color bar + SportColorBar(sport: game.game.sport) + + VStack(alignment: .leading, spacing: 4) { + // Sport badge + Matchup + HStack(spacing: 6) { + HStack(spacing: 3) { + Image(systemName: game.game.sport.iconName) + .font(.caption2) + Text(game.game.sport.rawValue) + .font(.caption2) + } + .foregroundStyle(game.game.sport.themeColor) + + HStack(spacing: 4) { + Text(game.awayTeam.abbreviation) + .font(.body) + Text("@") + .foregroundStyle(Theme.textMuted(colorScheme)) + Text(game.homeTeam.abbreviation) + .font(.body) + } + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + // Stadium + HStack(spacing: 4) { + Image(systemName: "building.2") + .font(.caption2) + Text(game.stadium.name) + .font(.subheadline) + } + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + Spacer() + + // Time + Text(game.localGameTimeShort) + .font(.subheadline) + .foregroundStyle(Theme.warmOrange) + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .strokeBorder(game.game.sport.themeColor.opacity(0.3), lineWidth: 1) + } + } +} diff --git a/SportsTime/Features/Trip/Views/ItineraryRows/TravelItemRow.swift b/SportsTime/Features/Trip/Views/ItineraryRows/TravelItemRow.swift new file mode 100644 index 0000000..6d4e389 --- /dev/null +++ b/SportsTime/Features/Trip/Views/ItineraryRows/TravelItemRow.swift @@ -0,0 +1,94 @@ +// +// TravelItemRow.swift +// SportsTime +// +// Row view for displaying travel segments in the itinerary. +// Uses gold/amber styling and includes a drag handle for reordering. +// + +import SwiftUI + +struct TravelItemRow: View { + let item: ItineraryItem + let isHighlighted: Bool + + @Environment(\.colorScheme) private var colorScheme + + private var travelInfo: TravelInfo? { + item.travelInfo + } + + var body: some View { + HStack(spacing: Theme.Spacing.md) { + // Drag handle + Image(systemName: "line.3.horizontal") + .font(.title3) + .foregroundStyle(Theme.textMuted(colorScheme)) + + // Car icon + ZStack { + Circle() + .fill(Theme.routeGold.opacity(0.2)) + .frame(width: 36, height: 36) + + Image(systemName: "car.fill") + .font(.body) + .foregroundStyle(Theme.routeGold) + } + + VStack(alignment: .leading, spacing: 2) { + if let info = travelInfo { + Text("\(info.fromCity) \u{2192} \(info.toCity)") + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + HStack(spacing: Theme.Spacing.xs) { + if !info.formattedDistance.isEmpty { + Text(info.formattedDistance) + .font(.caption) + } + if !info.formattedDistance.isEmpty && !info.formattedDuration.isEmpty { + Text("\u{2022}") + } + if !info.formattedDuration.isEmpty { + Text(info.formattedDuration) + .font(.caption) + } + } + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + } + + Spacer() + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .strokeBorder(isHighlighted ? Theme.routeGold : Theme.routeGold.opacity(0.3), lineWidth: isHighlighted ? 2 : 1) + } + } +} + +#Preview { + let travelInfo = TravelInfo( + fromCity: "Boston", + toCity: "New York", + distanceMeters: 350000, + durationSeconds: 14400 + ) + let item = ItineraryItem( + tripId: UUID(), + day: 1, + sortOrder: 1.0, + kind: .travel(travelInfo) + ) + + VStack(spacing: 16) { + TravelItemRow(item: item, isHighlighted: false) + TravelItemRow(item: item, isHighlighted: true) + } + .padding() + .background(Color.gray.opacity(0.1)) +}