Files
Sportstime/SportsTime/Export/Sharing/AchievementCardGenerator.swift
Trey t 244ea5e107 feat: redesign all share cards, remove unused achievement types, fix sport selector
Redesign trip, progress, and achievement share cards with premium
sports-media aesthetic. Remove unused milestone/context achievement card
types (only used in debug exporter). Fix gold text unreadable in light
mode. Fix sport selector to only show stroke on selected sport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:55:53 -06:00

282 lines
9.0 KiB
Swift

//
// AchievementCardGenerator.swift
// SportsTime
//
// Shareable achievement cards unified design language.
// Solid color bg, ghost text, rounded-square badge with gold stroke,
// plain white text (no panels/borders), app icon footer.
//
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 renderer = ImageRenderer(content: AchievementSpotlightView(achievement: achievement, theme: theme))
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> = []
var filterSport: Sport? = nil
var cardType: ShareCardType { .achievementCollection }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let renderer = ImageRenderer(content: AchievementCollectionView(
achievements: achievements,
year: year,
sports: sports,
filterSport: filterSport,
theme: theme
))
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
var body: some View {
ZStack {
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
Text(achievement.definition.name.uppercased())
.font(.system(size: 90, weight: .black))
.foregroundStyle(theme.textColor.opacity(0.07))
.multilineTextAlignment(.center)
.lineLimit(4)
.minimumScaleFactor(0.4)
.padding(.horizontal, 20)
VStack(spacing: 0) {
Spacer()
AchievementBadge(
definition: achievement.definition,
isEarned: achievement.earnedAt != nil,
size: 360
)
Spacer().frame(height: 44)
Text(achievement.definition.name.uppercased())
.font(.system(size: 52, weight: .black))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
.lineLimit(3)
.minimumScaleFactor(0.6)
.padding(.horizontal, 60)
Spacer().frame(height: 16)
Text(achievement.definition.description)
.font(.system(size: 22, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
.multilineTextAlignment(.center)
.padding(.horizontal, 80)
if let date = achievement.earnedAt {
Text(date.formatted(date: .abbreviated, time: .omitted).uppercased())
.font(.system(size: 18, weight: .bold))
.tracking(2)
.foregroundStyle(theme.secondaryTextColor)
.padding(.top, 14)
}
Spacer()
AchievementCardAppFooter(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: 24),
GridItem(.flexible(), spacing: 24),
GridItem(.flexible(), spacing: 24)
]
private var sportLabel: String {
filterSport?.displayName.uppercased() ?? "ALL SPORTS"
}
var body: some View {
ZStack {
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
VStack(spacing: 0) {
// Header
Text(sportLabel)
.font(.system(size: 20, weight: .black))
.tracking(8)
.foregroundStyle(theme.secondaryTextColor)
.padding(.top, 20)
Text("ACHIEVEMENTS")
.font(.system(size: 44, weight: .black))
.foregroundStyle(theme.textColor)
.padding(.top, 8)
Text("\(achievements.count) UNLOCKED \u{2022} \(year)")
.font(.system(size: 18, weight: .bold))
.tracking(2)
.foregroundStyle(theme.accentColor)
.padding(.top, 8)
Spacer()
// Badge grid
LazyVGrid(columns: columns, spacing: 28) {
ForEach(Array(achievements.prefix(9).enumerated()), id: \.offset) { _, item in
VStack(spacing: 12) {
AchievementBadge(
definition: item.definition,
isEarned: item.earnedAt != nil,
size: 180
)
Text(item.definition.name)
.font(.system(size: 18, weight: .bold))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(height: 44)
}
}
}
.padding(.horizontal, 30)
Spacer()
AchievementCardAppFooter(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 isEarned: Bool
let size: CGFloat
private let gold = Color(hex: "FFD700")
private let goldDark = Color(hex: "B8860B")
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: size * 0.22)
.fill(
LinearGradient(
colors: [definition.iconColor.opacity(0.3), definition.iconColor.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: size, height: size)
RoundedRectangle(cornerRadius: size * 0.22)
.stroke(
LinearGradient(colors: [gold, goldDark], startPoint: .topLeading, endPoint: .bottomTrailing),
lineWidth: 3
)
.frame(width: size, height: size)
Image(systemName: definition.iconName)
.font(.system(size: size * 0.42, weight: .bold))
.foregroundStyle(definition.iconColor)
if isEarned {
Circle()
.fill(gold)
.frame(width: size * 0.17, height: size * 0.17)
.overlay {
Image(systemName: "checkmark")
.font(.system(size: size * 0.078, weight: .black))
.foregroundStyle(Color.black.opacity(0.75))
}
.offset(x: size * 0.35, y: -size * 0.35)
}
}
.frame(width: size, height: size)
.shadow(color: gold.opacity(0.3), radius: 12, y: 6)
}
}
// MARK: - App Footer
private struct AchievementCardAppFooter: View {
let theme: ShareTheme
var body: some View {
VStack(spacing: 8) {
if let icon = Self.loadAppIcon() {
Image(uiImage: icon)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 13))
}
Text("SportsTime")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(theme.textColor.opacity(0.5))
}
.padding(.bottom, 10)
}
private static func loadAppIcon() -> UIImage? {
if let icons = Bundle.main.infoDictionary?["CFBundleIcons"] as? [String: Any],
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
let files = primary["CFBundleIconFiles"] as? [String],
let name = files.last {
return UIImage(named: name)
}
return nil
}
}