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 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,8 @@ struct AchievementSpotlightContent: ShareableContent {
|
|||||||
struct AchievementCollectionContent: ShareableContent {
|
struct AchievementCollectionContent: ShareableContent {
|
||||||
let achievements: [AchievementProgress]
|
let achievements: [AchievementProgress]
|
||||||
let year: Int
|
let year: Int
|
||||||
|
var sports: Set<Sport> = [] // Sports for background icons
|
||||||
|
var filterSport: Sport? = nil // The sport filter applied (for header title)
|
||||||
|
|
||||||
var cardType: ShareCardType { .achievementCollection }
|
var cardType: ShareCardType { .achievementCollection }
|
||||||
|
|
||||||
@@ -46,6 +48,8 @@ struct AchievementCollectionContent: ShareableContent {
|
|||||||
let cardView = AchievementCollectionView(
|
let cardView = AchievementCollectionView(
|
||||||
achievements: achievements,
|
achievements: achievements,
|
||||||
year: year,
|
year: year,
|
||||||
|
sports: sports,
|
||||||
|
filterSport: filterSport,
|
||||||
theme: theme
|
theme: theme
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,9 +124,16 @@ private struct AchievementSpotlightView: View {
|
|||||||
let achievement: AchievementProgress
|
let achievement: AchievementProgress
|
||||||
let theme: ShareTheme
|
let theme: ShareTheme
|
||||||
|
|
||||||
|
private var sports: Set<Sport> {
|
||||||
|
if let sport = achievement.definition.sport {
|
||||||
|
return [sport]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ShareCardBackground(theme: theme)
|
ShareCardBackground(theme: theme, sports: sports)
|
||||||
|
|
||||||
VStack(spacing: 50) {
|
VStack(spacing: 50) {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -176,6 +187,8 @@ private struct AchievementSpotlightView: View {
|
|||||||
private struct AchievementCollectionView: View {
|
private struct AchievementCollectionView: View {
|
||||||
let achievements: [AchievementProgress]
|
let achievements: [AchievementProgress]
|
||||||
let year: Int
|
let year: Int
|
||||||
|
let sports: Set<Sport>
|
||||||
|
let filterSport: Sport?
|
||||||
let theme: ShareTheme
|
let theme: ShareTheme
|
||||||
|
|
||||||
private let columns = [
|
private let columns = [
|
||||||
@@ -184,13 +197,20 @@ private struct AchievementCollectionView: View {
|
|||||||
GridItem(.flexible(), spacing: 30)
|
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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ShareCardBackground(theme: theme)
|
ShareCardBackground(theme: theme, sports: sports)
|
||||||
|
|
||||||
VStack(spacing: 40) {
|
VStack(spacing: 40) {
|
||||||
// Header
|
// Header
|
||||||
Text("My \(String(year)) Achievements")
|
Text(headerTitle)
|
||||||
.font(.system(size: 48, weight: .bold, design: .rounded))
|
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(theme.textColor)
|
.foregroundStyle(theme.textColor)
|
||||||
|
|
||||||
@@ -241,9 +261,16 @@ private struct AchievementMilestoneView: View {
|
|||||||
|
|
||||||
private let goldColor = Color(hex: "FFD700")
|
private let goldColor = Color(hex: "FFD700")
|
||||||
|
|
||||||
|
private var sports: Set<Sport> {
|
||||||
|
if let sport = achievement.definition.sport {
|
||||||
|
return [sport]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ShareCardBackground(theme: theme)
|
ShareCardBackground(theme: theme, sports: sports)
|
||||||
|
|
||||||
// Confetti burst pattern
|
// Confetti burst pattern
|
||||||
ConfettiBurst()
|
ConfettiBurst()
|
||||||
@@ -304,9 +331,16 @@ private struct AchievementContextView: View {
|
|||||||
let mapSnapshot: UIImage?
|
let mapSnapshot: UIImage?
|
||||||
let theme: ShareTheme
|
let theme: ShareTheme
|
||||||
|
|
||||||
|
private var sports: Set<Sport> {
|
||||||
|
if let sport = achievement.definition.sport {
|
||||||
|
return [sport]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ShareCardBackground(theme: theme)
|
ShareCardBackground(theme: theme, sports: sports)
|
||||||
|
|
||||||
VStack(spacing: 40) {
|
VStack(spacing: 40) {
|
||||||
// Header with badge and name
|
// Header with badge and name
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ private struct ProgressCardView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ShareCardBackground(theme: theme)
|
ShareCardBackground(theme: theme, sports: [progress.sport])
|
||||||
|
|
||||||
VStack(spacing: 40) {
|
VStack(spacing: 40) {
|
||||||
ShareCardHeader(
|
ShareCardHeader(
|
||||||
|
|||||||
@@ -13,14 +13,19 @@ import UIKit
|
|||||||
|
|
||||||
struct ShareCardBackground: View {
|
struct ShareCardBackground: View {
|
||||||
let theme: ShareTheme
|
let theme: ShareTheme
|
||||||
|
var sports: Set<Sport>? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if let sports = sports, !sports.isEmpty {
|
||||||
|
ShareCardSportBackground(sports: sports, theme: theme)
|
||||||
|
} else {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: theme.gradientColors,
|
colors: theme.gradientColors,
|
||||||
startPoint: .top,
|
startPoint: .top,
|
||||||
endPoint: .bottom
|
endPoint: .bottom
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Card Header
|
// MARK: - Card Header
|
||||||
|
|||||||
80
SportsTime/Export/Sharing/ShareCardSportBackground.swift
Normal file
80
SportsTime/Export/Sharing/ShareCardSportBackground.swift
Normal file
@@ -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<Sport>
|
||||||
|
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..<iconConfigs.count, id: \.self) { index in
|
||||||
|
let config = iconConfigs[index]
|
||||||
|
Image(systemName: iconName(at: index))
|
||||||
|
.font(.system(size: 32 * config.scale))
|
||||||
|
.foregroundStyle(theme.accentColor.opacity(0.15))
|
||||||
|
.rotationEffect(.degrees(config.rotation))
|
||||||
|
.position(
|
||||||
|
x: geo.size.width * config.x,
|
||||||
|
y: geo.size.height * config.y
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Single Sport - MLB") {
|
||||||
|
ShareCardSportBackground(
|
||||||
|
sports: [.mlb],
|
||||||
|
theme: .sunset
|
||||||
|
)
|
||||||
|
.frame(width: 400, height: 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Multiple Sports") {
|
||||||
|
ShareCardSportBackground(
|
||||||
|
sports: [.mlb, .nba, .nfl],
|
||||||
|
theme: .dark
|
||||||
|
)
|
||||||
|
.frame(width: 400, height: 600)
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ private struct TripCardView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ShareCardBackground(theme: theme)
|
ShareCardBackground(theme: theme, sports: trip.uniqueSports)
|
||||||
|
|
||||||
VStack(spacing: 40) {
|
VStack(spacing: 40) {
|
||||||
ShareCardHeader(
|
ShareCardHeader(
|
||||||
|
|||||||
@@ -38,11 +38,27 @@ struct AchievementsListView: View {
|
|||||||
.navigationTitle("Achievements")
|
.navigationTitle("Achievements")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
if !earnedAchievements.isEmpty {
|
// Share only achievements matching current filter
|
||||||
|
let achievementsToShare = selectedSport != nil
|
||||||
|
? filteredAchievements.filter { $0.isEarned }
|
||||||
|
: earnedAchievements
|
||||||
|
|
||||||
|
// Collect sports for background: single sport if filtered, all sports from achievements if "All"
|
||||||
|
let sportsForBackground: Set<Sport> = {
|
||||||
|
if let sport = selectedSport {
|
||||||
|
return [sport]
|
||||||
|
}
|
||||||
|
// Extract all unique sports from earned achievements
|
||||||
|
return Set(achievementsToShare.compactMap { $0.definition.sport })
|
||||||
|
}()
|
||||||
|
|
||||||
|
if !achievementsToShare.isEmpty {
|
||||||
ShareButton(
|
ShareButton(
|
||||||
content: AchievementCollectionContent(
|
content: AchievementCollectionContent(
|
||||||
achievements: earnedAchievements,
|
achievements: achievementsToShare,
|
||||||
year: Calendar.current.component(.year, from: Date())
|
year: Calendar.current.component(.year, from: Date()),
|
||||||
|
sports: sportsForBackground,
|
||||||
|
filterSport: selectedSport
|
||||||
),
|
),
|
||||||
style: .icon
|
style: .icon
|
||||||
)
|
)
|
||||||
@@ -573,6 +589,16 @@ struct AchievementDetailSheet: View {
|
|||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Done") { dismiss() }
|
Button("Done") { dismiss() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if achievement.isEarned {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
ShareButton(
|
||||||
|
content: AchievementSpotlightContent(achievement: achievement),
|
||||||
|
style: .icon
|
||||||
|
)
|
||||||
|
.foregroundStyle(completedGold)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,17 @@ final class TripWizardViewModel {
|
|||||||
hasSetRepeatCities
|
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
|
// MARK: - Sport Availability
|
||||||
|
|
||||||
func canSelectSport(_ sport: Sport) -> Bool {
|
func canSelectSport(_ sport: Sport) -> Bool {
|
||||||
@@ -120,3 +131,18 @@ final class TripWizardViewModel {
|
|||||||
mustStopLocations = []
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct ReviewStep: View {
|
|||||||
let mustStopLocations: [LocationInput]
|
let mustStopLocations: [LocationInput]
|
||||||
let isPlanning: Bool
|
let isPlanning: Bool
|
||||||
let canPlanTrip: Bool
|
let canPlanTrip: Bool
|
||||||
|
let fieldValidation: FieldValidation
|
||||||
let onPlan: () -> Void
|
let onPlan: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -30,11 +31,31 @@ struct ReviewStep: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
ReviewRow(label: "Mode", value: planningMode.displayName)
|
ReviewRow(label: "Mode", value: planningMode.displayName)
|
||||||
ReviewRow(label: "Sports", value: selectedSports.map(\.rawValue).sorted().joined(separator: ", "))
|
ReviewRow(
|
||||||
ReviewRow(label: "Dates", value: dateRangeText)
|
label: "Sports",
|
||||||
ReviewRow(label: "Regions", value: selectedRegions.map(\.shortName).sorted().joined(separator: ", "))
|
value: selectedSports.isEmpty ? "Not selected" : selectedSports.map(\.rawValue).sorted().joined(separator: ", "),
|
||||||
ReviewRow(label: "Route", value: routePreference.displayName)
|
isMissing: fieldValidation.sports == .missing
|
||||||
ReviewRow(label: "Repeat cities", value: allowRepeatCities ? "Yes" : "No")
|
)
|
||||||
|
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 {
|
if !mustStopLocations.isEmpty {
|
||||||
ReviewRow(label: "Must-stops", value: mustStopLocations.map(\.name).joined(separator: ", "))
|
ReviewRow(label: "Must-stops", value: mustStopLocations.map(\.name).joined(separator: ", "))
|
||||||
@@ -83,26 +104,33 @@ private struct ReviewRow: View {
|
|||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
let label: String
|
let label: String
|
||||||
let value: String
|
let value: String
|
||||||
|
var isMissing: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
.foregroundStyle(isMissing ? .red : Theme.textMuted(colorScheme))
|
||||||
.frame(width: 80, alignment: .leading)
|
.frame(width: 80, alignment: .leading)
|
||||||
|
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
.foregroundStyle(isMissing ? .red : Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
if isMissing {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|
||||||
#Preview {
|
#Preview("All Valid") {
|
||||||
ReviewStep(
|
ReviewStep(
|
||||||
planningMode: .dateRange,
|
planningMode: .dateRange,
|
||||||
selectedSports: [.mlb, .nba],
|
selectedSports: [.mlb, .nba],
|
||||||
@@ -114,6 +142,38 @@ private struct ReviewRow: View {
|
|||||||
mustStopLocations: [],
|
mustStopLocations: [],
|
||||||
isPlanning: false,
|
isPlanning: false,
|
||||||
canPlanTrip: true,
|
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: {}
|
onPlan: {}
|
||||||
)
|
)
|
||||||
.padding()
|
.padding()
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ struct TripWizardView: View {
|
|||||||
mustStopLocations: viewModel.mustStopLocations,
|
mustStopLocations: viewModel.mustStopLocations,
|
||||||
isPlanning: viewModel.isPlanning,
|
isPlanning: viewModel.isPlanning,
|
||||||
canPlanTrip: viewModel.canPlanTrip,
|
canPlanTrip: viewModel.canPlanTrip,
|
||||||
|
fieldValidation: viewModel.fieldValidation,
|
||||||
onPlan: { Task { await planTrip() } }
|
onPlan: { Task { await planTrip() } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user