From 3d4952e5ffddc739ec527dbbefdabce8dff74e06 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 14 Jan 2026 12:02:57 -0600 Subject: [PATCH] feat(ui): add sport backgrounds to share cards, achievement filtering, and wizard validation - Add ShareCardSportBackground with floating sport icons for share cards - Share cards now show sport-specific backgrounds (single or multiple sports) - Achievement collection share respects sport filter selection - Add ability to share individual achievements from detail sheet - Trip wizard ReviewStep highlights missing required fields in red - Add FieldValidation model to TripWizardViewModel Co-Authored-By: Claude Opus 4.5 --- .../Sharing/AchievementCardGenerator.swift | 44 ++++++++-- .../Sharing/ProgressCardGenerator.swift | 2 +- .../Export/Sharing/ShareCardComponents.swift | 15 ++-- .../Sharing/ShareCardSportBackground.swift | 80 +++++++++++++++++++ .../Export/Sharing/TripCardGenerator.swift | 2 +- .../Progress/Views/AchievementsListView.swift | 32 +++++++- .../Trip/ViewModels/TripWizardViewModel.swift | 26 ++++++ .../Trip/Views/Wizard/Steps/ReviewStep.swift | 76 ++++++++++++++++-- .../Trip/Views/Wizard/TripWizardView.swift | 1 + 9 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 SportsTime/Export/Sharing/ShareCardSportBackground.swift diff --git a/SportsTime/Export/Sharing/AchievementCardGenerator.swift b/SportsTime/Export/Sharing/AchievementCardGenerator.swift index a35834c..1ebb34b 100644 --- a/SportsTime/Export/Sharing/AchievementCardGenerator.swift +++ b/SportsTime/Export/Sharing/AchievementCardGenerator.swift @@ -38,6 +38,8 @@ struct AchievementSpotlightContent: ShareableContent { struct AchievementCollectionContent: ShareableContent { let achievements: [AchievementProgress] let year: Int + var sports: Set = [] // Sports for background icons + var filterSport: Sport? = nil // The sport filter applied (for header title) var cardType: ShareCardType { .achievementCollection } @@ -46,6 +48,8 @@ struct AchievementCollectionContent: ShareableContent { let cardView = AchievementCollectionView( achievements: achievements, year: year, + sports: sports, + filterSport: filterSport, theme: theme ) @@ -120,9 +124,16 @@ private struct AchievementSpotlightView: View { let achievement: AchievementProgress let theme: ShareTheme + private var sports: Set { + if let sport = achievement.definition.sport { + return [sport] + } + return [] + } + var body: some View { ZStack { - ShareCardBackground(theme: theme) + ShareCardBackground(theme: theme, sports: sports) VStack(spacing: 50) { Spacer() @@ -176,6 +187,8 @@ private struct AchievementSpotlightView: View { private struct AchievementCollectionView: View { let achievements: [AchievementProgress] let year: Int + let sports: Set + let filterSport: Sport? let theme: ShareTheme private let columns = [ @@ -184,13 +197,20 @@ private struct AchievementCollectionView: View { GridItem(.flexible(), spacing: 30) ] + private var headerTitle: String { + if let sport = filterSport { + return "My \(String(year)) \(sport.rawValue) Achievements" + } + return "My \(String(year)) Achievements" + } + var body: some View { ZStack { - ShareCardBackground(theme: theme) + ShareCardBackground(theme: theme, sports: sports) VStack(spacing: 40) { // Header - Text("My \(String(year)) Achievements") + Text(headerTitle) .font(.system(size: 48, weight: .bold, design: .rounded)) .foregroundStyle(theme.textColor) @@ -241,9 +261,16 @@ private struct AchievementMilestoneView: View { private let goldColor = Color(hex: "FFD700") + private var sports: Set { + if let sport = achievement.definition.sport { + return [sport] + } + return [] + } + var body: some View { ZStack { - ShareCardBackground(theme: theme) + ShareCardBackground(theme: theme, sports: sports) // Confetti burst pattern ConfettiBurst() @@ -304,9 +331,16 @@ private struct AchievementContextView: View { let mapSnapshot: UIImage? let theme: ShareTheme + private var sports: Set { + if let sport = achievement.definition.sport { + return [sport] + } + return [] + } + var body: some View { ZStack { - ShareCardBackground(theme: theme) + ShareCardBackground(theme: theme, sports: sports) VStack(spacing: 40) { // Header with badge and name diff --git a/SportsTime/Export/Sharing/ProgressCardGenerator.swift b/SportsTime/Export/Sharing/ProgressCardGenerator.swift index c591308..6321311 100644 --- a/SportsTime/Export/Sharing/ProgressCardGenerator.swift +++ b/SportsTime/Export/Sharing/ProgressCardGenerator.swift @@ -53,7 +53,7 @@ private struct ProgressCardView: View { var body: some View { ZStack { - ShareCardBackground(theme: theme) + ShareCardBackground(theme: theme, sports: [progress.sport]) VStack(spacing: 40) { ShareCardHeader( diff --git a/SportsTime/Export/Sharing/ShareCardComponents.swift b/SportsTime/Export/Sharing/ShareCardComponents.swift index 1b722f8..b7a5d32 100644 --- a/SportsTime/Export/Sharing/ShareCardComponents.swift +++ b/SportsTime/Export/Sharing/ShareCardComponents.swift @@ -13,13 +13,18 @@ import UIKit struct ShareCardBackground: View { let theme: ShareTheme + var sports: Set? = nil var body: some View { - LinearGradient( - colors: theme.gradientColors, - startPoint: .top, - endPoint: .bottom - ) + if let sports = sports, !sports.isEmpty { + ShareCardSportBackground(sports: sports, theme: theme) + } else { + LinearGradient( + colors: theme.gradientColors, + startPoint: .top, + endPoint: .bottom + ) + } } } diff --git a/SportsTime/Export/Sharing/ShareCardSportBackground.swift b/SportsTime/Export/Sharing/ShareCardSportBackground.swift new file mode 100644 index 0000000..94ef38c --- /dev/null +++ b/SportsTime/Export/Sharing/ShareCardSportBackground.swift @@ -0,0 +1,80 @@ +// +// ShareCardSportBackground.swift +// SportsTime +// +// Sport-specific background with floating league icons for share cards. +// + +import SwiftUI + +struct ShareCardSportBackground: View { + let sports: Set + let theme: ShareTheme + + /// Fixed positions for 12 scattered icons (x, y as percentage, rotation, scale) + private let iconConfigs: [(x: CGFloat, y: CGFloat, rotation: Double, scale: CGFloat)] = [ + (0.08, 0.08, -20, 0.9), + (0.92, 0.05, 15, 0.85), + (0.15, 0.28, 25, 0.8), + (0.88, 0.22, -10, 0.95), + (0.05, 0.48, 30, 0.85), + (0.95, 0.45, -25, 0.9), + (0.12, 0.68, -15, 0.8), + (0.90, 0.65, 20, 0.85), + (0.08, 0.88, 10, 0.9), + (0.92, 0.85, -30, 0.8), + (0.50, 0.15, 5, 0.75), + (0.50, 0.90, -5, 0.75) + ] + + /// Get icon name for a given index, cycling through sports + private func iconName(at index: Int) -> String { + let sportArray = Array(sports).sorted { $0.rawValue < $1.rawValue } + guard !sportArray.isEmpty else { + return "sportscourt.fill" + } + return sportArray[index % sportArray.count].iconName + } + + var body: some View { + ZStack { + // Base gradient + LinearGradient( + colors: theme.gradientColors, + startPoint: .top, + endPoint: .bottom + ) + + // Scattered sport icons + GeometryReader { geo in + ForEach(0.. = { + if let sport = selectedSport { + return [sport] + } + // Extract all unique sports from earned achievements + return Set(achievementsToShare.compactMap { $0.definition.sport }) + }() + + if !achievementsToShare.isEmpty { ShareButton( content: AchievementCollectionContent( - achievements: earnedAchievements, - year: Calendar.current.component(.year, from: Date()) + achievements: achievementsToShare, + year: Calendar.current.component(.year, from: Date()), + sports: sportsForBackground, + filterSport: selectedSport ), style: .icon ) @@ -573,6 +589,16 @@ struct AchievementDetailSheet: View { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } + + if achievement.isEarned { + ToolbarItem(placement: .primaryAction) { + ShareButton( + content: AchievementSpotlightContent(achievement: achievement), + style: .icon + ) + .foregroundStyle(completedGold) + } + } } } } diff --git a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift index 6009ed6..9571e9d 100644 --- a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift +++ b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift @@ -77,6 +77,17 @@ final class TripWizardViewModel { hasSetRepeatCities } + /// Field validation for the review step - shows which fields are missing + var fieldValidation: FieldValidation { + FieldValidation( + sports: selectedSports.isEmpty ? .missing : .valid, + dates: hasSetDates ? .valid : .missing, + regions: selectedRegions.isEmpty ? .missing : .valid, + routePreference: hasSetRoutePreference ? .valid : .missing, + repeatCities: hasSetRepeatCities ? .valid : .missing + ) + } + // MARK: - Sport Availability func canSelectSport(_ sport: Sport) -> Bool { @@ -120,3 +131,18 @@ final class TripWizardViewModel { mustStopLocations = [] } } + +// MARK: - Field Validation + +struct FieldValidation { + enum Status { + case valid + case missing + } + + let sports: Status + let dates: Status + let regions: Status + let routePreference: Status + let repeatCities: Status +} diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift index 8b85874..fcc2fd6 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift @@ -19,6 +19,7 @@ struct ReviewStep: View { let mustStopLocations: [LocationInput] let isPlanning: Bool let canPlanTrip: Bool + let fieldValidation: FieldValidation let onPlan: () -> Void var body: some View { @@ -30,11 +31,31 @@ struct ReviewStep: View { VStack(alignment: .leading, spacing: Theme.Spacing.sm) { ReviewRow(label: "Mode", value: planningMode.displayName) - ReviewRow(label: "Sports", value: selectedSports.map(\.rawValue).sorted().joined(separator: ", ")) - ReviewRow(label: "Dates", value: dateRangeText) - ReviewRow(label: "Regions", value: selectedRegions.map(\.shortName).sorted().joined(separator: ", ")) - ReviewRow(label: "Route", value: routePreference.displayName) - ReviewRow(label: "Repeat cities", value: allowRepeatCities ? "Yes" : "No") + ReviewRow( + label: "Sports", + value: selectedSports.isEmpty ? "Not selected" : selectedSports.map(\.rawValue).sorted().joined(separator: ", "), + isMissing: fieldValidation.sports == .missing + ) + ReviewRow( + label: "Dates", + value: dateRangeText, + isMissing: fieldValidation.dates == .missing + ) + ReviewRow( + label: "Regions", + value: selectedRegions.isEmpty ? "Not selected" : selectedRegions.map(\.shortName).sorted().joined(separator: ", "), + isMissing: fieldValidation.regions == .missing + ) + ReviewRow( + label: "Route", + value: routePreference.displayName, + isMissing: fieldValidation.routePreference == .missing + ) + ReviewRow( + label: "Repeat cities", + value: allowRepeatCities ? "Yes" : "No", + isMissing: fieldValidation.repeatCities == .missing + ) if !mustStopLocations.isEmpty { ReviewRow(label: "Must-stops", value: mustStopLocations.map(\.name).joined(separator: ", ")) @@ -83,26 +104,33 @@ private struct ReviewRow: View { @Environment(\.colorScheme) private var colorScheme let label: String let value: String + var isMissing: Bool = false var body: some View { HStack(alignment: .top) { Text(label) .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) + .foregroundStyle(isMissing ? .red : Theme.textMuted(colorScheme)) .frame(width: 80, alignment: .leading) Text(value) .font(.subheadline) - .foregroundStyle(Theme.textPrimary(colorScheme)) + .foregroundStyle(isMissing ? .red : Theme.textPrimary(colorScheme)) Spacer() + + if isMissing { + Image(systemName: "exclamationmark.circle.fill") + .font(.caption) + .foregroundStyle(.red) + } } } } // MARK: - Preview -#Preview { +#Preview("All Valid") { ReviewStep( planningMode: .dateRange, selectedSports: [.mlb, .nba], @@ -114,6 +142,38 @@ private struct ReviewRow: View { mustStopLocations: [], isPlanning: false, canPlanTrip: true, + fieldValidation: FieldValidation( + sports: .valid, + dates: .valid, + regions: .valid, + routePreference: .valid, + repeatCities: .valid + ), + onPlan: {} + ) + .padding() + .themedBackground() +} + +#Preview("Missing Fields") { + ReviewStep( + planningMode: .dateRange, + selectedSports: [], + startDate: Date(), + endDate: Date().addingTimeInterval(86400 * 7), + selectedRegions: [], + routePreference: .balanced, + allowRepeatCities: false, + mustStopLocations: [], + isPlanning: false, + canPlanTrip: false, + fieldValidation: FieldValidation( + sports: .missing, + dates: .valid, + regions: .missing, + routePreference: .valid, + repeatCities: .missing + ), onPlan: {} ) .padding() diff --git a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift index 4df7a9a..7848e4e 100644 --- a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift +++ b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift @@ -72,6 +72,7 @@ struct TripWizardView: View { mustStopLocations: viewModel.mustStopLocations, isPlanning: viewModel.isPlanning, canPlanTrip: viewModel.canPlanTrip, + fieldValidation: viewModel.fieldValidation, onPlan: { Task { await planTrip() } } ) }