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>
282 lines
9.0 KiB
Swift
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
|
|
}
|
|
}
|