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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user