Replace old ProgressCardGenerator with protocol-based sharing architecture supporting trips, achievements, and stadium progress. Features 8 color themes, Instagram Stories optimization (1080x1920), and reusable card components with map snapshots. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
424 lines
12 KiB
Swift
424 lines
12 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 cardType: ShareCardType { .achievementCollection }
|
|
|
|
@MainActor
|
|
func render(theme: ShareTheme) async throws -> UIImage {
|
|
let cardView = AchievementCollectionView(
|
|
achievements: achievements,
|
|
year: year,
|
|
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
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
ShareCardBackground(theme: theme)
|
|
|
|
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)
|
|
|
|
// 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 theme: ShareTheme
|
|
|
|
private let columns = [
|
|
GridItem(.flexible(), spacing: 30),
|
|
GridItem(.flexible(), spacing: 30),
|
|
GridItem(.flexible(), spacing: 30)
|
|
]
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
ShareCardBackground(theme: theme)
|
|
|
|
VStack(spacing: 40) {
|
|
// Header
|
|
Text("My \(year) Achievements")
|
|
.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")
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
ShareCardBackground(theme: theme)
|
|
|
|
// 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)
|
|
|
|
// 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
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
ShareCardBackground(theme: theme)
|
|
|
|
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]
|
|
}
|
|
}
|