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>
This commit is contained in:
Trey t
2026-02-09 14:55:53 -06:00
parent 1a7ce78ae4
commit 244ea5e107
16 changed files with 3441 additions and 748 deletions

View File

@@ -2,7 +2,9 @@
// AchievementCardGenerator.swift
// SportsTime
//
// Generates shareable achievement cards: spotlight, collection, milestone, context.
// 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
@@ -17,18 +19,12 @@ struct AchievementSpotlightContent: ShareableContent {
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementSpotlightView(
achievement: achievement,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
let renderer = ImageRenderer(content: AchievementSpotlightView(achievement: achievement, theme: theme))
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
@@ -38,82 +34,25 @@ struct AchievementSpotlightContent: ShareableContent {
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 sports: Set<Sport> = []
var filterSport: Sport? = nil
var cardType: ShareCardType { .achievementCollection }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementCollectionView(
let renderer = ImageRenderer(content: 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
}
}
@@ -124,61 +63,61 @@ 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)
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
VStack(spacing: 50) {
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()
// Badge
AchievementBadge(
definition: achievement.definition,
size: 400
isEarned: achievement.earnedAt != nil,
size: 360
)
// Name
Text(achievement.definition.name)
.font(.system(size: 56, weight: .bold, design: .rounded))
Spacer().frame(height: 44)
Text(achievement.definition.name.uppercased())
.font(.system(size: 52, weight: .black))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(3)
.minimumScaleFactor(0.6)
.padding(.horizontal, 60)
Spacer().frame(height: 16)
// Description
Text(achievement.definition.description)
.font(.system(size: 28))
.font(.system(size: 22, weight: .medium))
.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)
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()
ShareCardFooter(theme: theme)
AchievementCardAppFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
}
}
@@ -192,212 +131,69 @@ private struct AchievementCollectionView: View {
let theme: ShareTheme
private let columns = [
GridItem(.flexible(), spacing: 30),
GridItem(.flexible(), spacing: 30),
GridItem(.flexible(), spacing: 30)
GridItem(.flexible(), spacing: 24),
GridItem(.flexible(), spacing: 24),
GridItem(.flexible(), spacing: 24)
]
private var headerTitle: String {
if let sport = filterSport {
return "My \(String(year)) \(sport.rawValue) Achievements"
}
return "My \(String(year)) Achievements"
private var sportLabel: String {
filterSport?.displayName.uppercased() ?? "ALL SPORTS"
}
var body: some View {
ZStack {
ShareCardBackground(theme: theme, sports: sports)
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
VStack(spacing: 40) {
VStack(spacing: 0) {
// Header
Text(headerTitle)
.font(.system(size: 48, weight: .bold, design: .rounded))
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()
// Grid
LazyVGrid(columns: columns, spacing: 40) {
ForEach(achievements.prefix(12)) { achievement in
// Badge grid
LazyVGrid(columns: columns, spacing: 28) {
ForEach(Array(achievements.prefix(9).enumerated()), id: \.offset) { _, item in
VStack(spacing: 12) {
AchievementBadge(
definition: achievement.definition,
size: 200
definition: item.definition,
isEarned: item.earnedAt != nil,
size: 180
)
Text(achievement.definition.name)
.font(.system(size: 18, weight: .semibold))
Text(item.definition.name)
.font(.system(size: 18, weight: .bold))
.foregroundStyle(theme.textColor)
.lineLimit(2)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(height: 44)
}
}
}
.padding(.horizontal, 40)
.padding(.horizontal, 30)
Spacer()
// Count
Text("\(achievements.count) achievements unlocked")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
ShareCardFooter(theme: theme)
AchievementCardAppFooter(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
)
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
}
}
@@ -405,57 +201,81 @@ private struct AchievementContextView: View {
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 {
Circle()
.fill(definition.iconColor.opacity(0.2))
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)
Circle()
.stroke(definition.iconColor, lineWidth: size * 0.02)
.frame(width: size * 0.9, height: size * 0.9)
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.4))
.font(.system(size: size * 0.42, weight: .bold))
.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
if isEarned {
Circle()
.fill(confettiColor(for: index))
.frame(width: CGFloat.random(in: 8...20))
.position(
x: center.x + xOffset,
y: center.y + yOffset
)
.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)
}
}
}
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]
.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
}
}