Files
Sportstime/SportsTime/Export/Sharing/ProgressCardGenerator.swift
Trey t 244ea5e107 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>
2026-02-09 14:55:53 -06:00

195 lines
6.1 KiB
Swift

//
// ProgressCardGenerator.swift
// SportsTime
//
// Shareable progress cards unified design language.
// Solid color bg, progress ring with fraction, plain white text, app icon footer.
//
import SwiftUI
import UIKit
// MARK: - Progress Share Content
struct ProgressShareContent: ShareableContent {
let progress: LeagueProgress
let tripCount: Int
var cardType: ShareCardType { .stadiumProgress }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let mapGenerator = ShareMapSnapshotGenerator()
let mapSnapshot = await mapGenerator.generateProgressMap(
visited: progress.stadiumsVisited,
remaining: progress.stadiumsRemaining,
theme: theme
)
let renderer = ImageRenderer(content: ProgressCardView(
progress: progress,
tripCount: tripCount,
theme: theme,
mapSnapshot: mapSnapshot
))
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
// MARK: - Progress Card View
private struct ProgressCardView: View {
let progress: LeagueProgress
let tripCount: Int
let theme: ShareTheme
let mapSnapshot: UIImage?
private let gold = Color(hex: "FFD700")
private var isComplete: Bool {
progress.completionPercentage >= 100
}
private var accent: Color {
isComplete ? gold : theme.accentColor
}
private var remaining: Int {
max(0, progress.totalStadiums - progress.visitedStadiums)
}
var body: some View {
ZStack {
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
VStack(spacing: 0) {
// Header
Text(progress.sport.displayName.uppercased())
.font(.system(size: 20, weight: .black))
.tracking(8)
.foregroundStyle(theme.secondaryTextColor)
.padding(.top, 20)
Text("STADIUM QUEST")
.font(.system(size: 44, weight: .black))
.foregroundStyle(theme.textColor)
.padding(.top, 8)
if isComplete {
Text("COMPLETE")
.font(.system(size: 18, weight: .black))
.tracking(4)
.foregroundStyle(gold)
.padding(.top, 6)
}
Spacer()
// Progress ring with fraction inside
ZStack {
Circle()
.stroke(theme.textColor.opacity(0.1), lineWidth: 24)
.frame(width: 400, height: 400)
Circle()
.trim(from: 0, to: progress.completionPercentage / 100)
.stroke(accent, style: StrokeStyle(lineWidth: 24, lineCap: .round))
.frame(width: 400, height: 400)
.rotationEffect(.degrees(-90))
VStack(spacing: 8) {
Text("\(progress.visitedStadiums)")
.font(.system(size: 120, weight: .black, design: .rounded))
.foregroundStyle(accent)
Rectangle()
.fill(theme.textColor.opacity(0.2))
.frame(width: 140, height: 3)
Text("\(progress.totalStadiums)")
.font(.system(size: 60, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor.opacity(0.4))
}
}
Spacer().frame(height: 40)
// Stats
HStack(spacing: 50) {
statItem(value: "\(progress.visitedStadiums)", label: "VISITED")
statItem(value: "\(remaining)", label: "TO GO")
statItem(value: "\(tripCount)", label: "TRIPS")
}
Spacer().frame(height: 40)
// Map
if let mapSnapshot {
Image(uiImage: mapSnapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 24))
.padding(.horizontal, 16)
}
Spacer()
ProgressCardAppFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
}
private func statItem(value: String, label: String) -> some View {
VStack(spacing: 6) {
Text(value)
.font(.system(size: 44, weight: .black, design: .rounded))
.foregroundStyle(accent)
Text(label)
.font(.system(size: 14, weight: .bold))
.tracking(2)
.foregroundStyle(theme.secondaryTextColor)
}
}
}
// MARK: - App Footer
private struct ProgressCardAppFooter: 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
}
}