feat(sharing): implement unified sharing system for social media
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>
This commit is contained in:
423
SportsTime/Export/Sharing/AchievementCardGenerator.swift
Normal file
423
SportsTime/Export/Sharing/AchievementCardGenerator.swift
Normal file
@@ -0,0 +1,423 @@
|
||||
//
|
||||
// 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]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user