Files
Sportstime/SportsTime/Export/Sharing/AchievementCardGenerator.swift
Trey t 3d4952e5ff 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>
2026-01-14 12:02:57 -06:00

462 lines
14 KiB
Swift

//
// AchievementCardGenerator.swift
// SportsTime
//
// Generates shareable achievement cards: spotlight, collection, milestone, context.
//
import SwiftUI
import UIKit
// MARK: - Achievement Spotlight Content
struct AchievementSpotlightContent: ShareableContent {
let achievement: AchievementProgress
var cardType: ShareCardType { .achievementSpotlight }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementSpotlightView(
achievement: achievement,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
// MARK: - Achievement Collection Content
struct AchievementCollectionContent: ShareableContent {
let achievements: [AchievementProgress]
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 }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementCollectionView(
achievements: achievements,
year: year,
sports: sports,
filterSport: filterSport,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
// MARK: - Achievement Milestone Content
struct AchievementMilestoneContent: ShareableContent {
let achievement: AchievementProgress
var cardType: ShareCardType { .achievementMilestone }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementMilestoneView(
achievement: achievement,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
// MARK: - Achievement Context Content
struct AchievementContextContent: ShareableContent {
let achievement: AchievementProgress
let tripName: String?
let mapSnapshot: UIImage?
var cardType: ShareCardType { .achievementContext }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementContextView(
achievement: achievement,
tripName: tripName,
mapSnapshot: mapSnapshot,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
// MARK: - Spotlight View
private struct AchievementSpotlightView: View {
let achievement: AchievementProgress
let theme: ShareTheme
private var sports: Set<Sport> {
if let sport = achievement.definition.sport {
return [sport]
}
return []
}
var body: some View {
ZStack {
ShareCardBackground(theme: theme, sports: sports)
VStack(spacing: 50) {
Spacer()
// Badge
AchievementBadge(
definition: achievement.definition,
size: 400
)
// Name
Text(achievement.definition.name)
.font(.system(size: 56, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
// Description
Text(achievement.definition.description)
.font(.system(size: 28))
.foregroundStyle(theme.secondaryTextColor)
.multilineTextAlignment(.center)
.padding(.horizontal, 80)
// Unlock date
if let earnedAt = achievement.earnedAt {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(theme.accentColor)
Text("Unlocked \(earnedAt.formatted(date: .abbreviated, time: .omitted))")
}
.font(.system(size: 24))
.foregroundStyle(theme.secondaryTextColor)
}
Spacer()
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
}
}
// MARK: - Collection View
private struct AchievementCollectionView: View {
let achievements: [AchievementProgress]
let year: Int
let sports: Set<Sport>
let filterSport: Sport?
let theme: ShareTheme
private let columns = [
GridItem(.flexible(), spacing: 30),
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 {
ZStack {
ShareCardBackground(theme: theme, sports: sports)
VStack(spacing: 40) {
// Header
Text(headerTitle)
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
Spacer()
// Grid
LazyVGrid(columns: columns, spacing: 40) {
ForEach(achievements.prefix(12)) { achievement in
VStack(spacing: 12) {
AchievementBadge(
definition: achievement.definition,
size: 200
)
Text(achievement.definition.name)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(theme.textColor)
.lineLimit(2)
.multilineTextAlignment(.center)
}
}
}
.padding(.horizontal, 40)
Spacer()
// Count
Text("\(achievements.count) achievements unlocked")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
}
}
// MARK: - Milestone View
private struct AchievementMilestoneView: View {
let achievement: AchievementProgress
let theme: ShareTheme
private let goldColor = Color(hex: "FFD700")
private var sports: Set<Sport> {
if let sport = achievement.definition.sport {
return [sport]
}
return []
}
var body: some View {
ZStack {
ShareCardBackground(theme: theme, sports: sports)
// Confetti burst pattern
ConfettiBurst()
.opacity(0.3)
VStack(spacing: 40) {
Spacer()
// Milestone label
Text("MILESTONE")
.font(.system(size: 24, weight: .black, design: .rounded))
.tracking(4)
.foregroundStyle(goldColor)
// Large badge
AchievementBadge(
definition: achievement.definition,
size: 500
)
.overlay {
Circle()
.stroke(goldColor, lineWidth: 4)
.frame(width: 520, height: 520)
}
// Name
Text(achievement.definition.name)
.font(.system(size: 56, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
// Description
Text(achievement.definition.description)
.font(.system(size: 28))
.foregroundStyle(theme.secondaryTextColor)
.multilineTextAlignment(.center)
.padding(.horizontal, 80)
Spacer()
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
}
}
// MARK: - Context View
private struct AchievementContextView: View {
let achievement: AchievementProgress
let tripName: String?
let mapSnapshot: UIImage?
let theme: ShareTheme
private var sports: Set<Sport> {
if let sport = achievement.definition.sport {
return [sport]
}
return []
}
var body: some View {
ZStack {
ShareCardBackground(theme: theme, sports: sports)
VStack(spacing: 40) {
// Header with badge and name
HStack(spacing: 24) {
AchievementBadge(
definition: achievement.definition,
size: 150
)
VStack(alignment: .leading, spacing: 8) {
Text(achievement.definition.name)
.font(.system(size: 40, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
Text("Unlocked!")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(theme.accentColor)
}
}
.padding(.top, 40)
Spacer()
// Context map or placeholder
if let snapshot = mapSnapshot {
Image(uiImage: snapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 960, maxHeight: 700)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(theme.accentColor.opacity(0.3), lineWidth: 2)
}
}
// Trip name
if let tripName = tripName {
Text("Unlocked during my")
.font(.system(size: 24))
.foregroundStyle(theme.secondaryTextColor)
Text(tripName)
.font(.system(size: 32, weight: .semibold))
.foregroundStyle(theme.textColor)
}
Spacer()
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
}
}
// MARK: - Achievement Badge
private struct AchievementBadge: View {
let definition: AchievementDefinition
let size: CGFloat
var body: some View {
ZStack {
Circle()
.fill(definition.iconColor.opacity(0.2))
.frame(width: size, height: size)
Circle()
.stroke(definition.iconColor, lineWidth: size * 0.02)
.frame(width: size * 0.9, height: size * 0.9)
Image(systemName: definition.iconName)
.font(.system(size: size * 0.4))
.foregroundStyle(definition.iconColor)
}
}
}
// MARK: - Confetti Burst
private struct ConfettiBurst: View {
var body: some View {
GeometryReader { geometry in
let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height * 0.4)
ForEach(0..<24, id: \.self) { index in
let angle = Double(index) * (360.0 / 24.0)
let distance: CGFloat = CGFloat.random(in: 200...400)
let xOffset = cos(angle * .pi / 180) * distance
let yOffset = sin(angle * .pi / 180) * distance
Circle()
.fill(confettiColor(for: index))
.frame(width: CGFloat.random(in: 8...20))
.position(
x: center.x + xOffset,
y: center.y + yOffset
)
}
}
}
private func confettiColor(for index: Int) -> Color {
let colors: [Color] = [
Color(hex: "FFD700"),
Color(hex: "FF6B35"),
Color(hex: "00D4FF"),
Color(hex: "95D5B2"),
Color(hex: "FF85A1")
]
return colors[index % colors.count]
}
}