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:
Trey t
2026-01-14 08:54:37 -06:00
parent 2b16420fb4
commit fe36f99bca
13 changed files with 1775 additions and 636 deletions

View File

@@ -1,606 +0,0 @@
//
// ProgressCardGenerator.swift
// SportsTime
//
// Generates shareable progress cards for social media.
// Cards include progress ring, stats, optional username, and app branding.
//
import SwiftUI
import UIKit
import MapKit
// MARK: - Progress Card Generator
@MainActor
final class ProgressCardGenerator {
// Card dimensions (Instagram story size)
private static let cardSize = CGSize(width: 1080, height: 1920)
private static let mapSnapshotSize = CGSize(width: 1000, height: 500)
// MARK: - Generate Card
/// Generate a shareable progress card image with default options
/// - Parameter progress: The league progress data
/// - Returns: The generated UIImage
func generateCard(progress: LeagueProgress) async throws -> UIImage {
try await generateCard(progress: progress, options: ProgressCardOptions())
}
/// Generate a shareable progress card image
/// - Parameters:
/// - progress: The league progress data
/// - options: Card generation options
/// - Returns: The generated UIImage
func generateCard(
progress: LeagueProgress,
options: ProgressCardOptions
) async throws -> UIImage {
// Generate map snapshot if needed
var mapSnapshot: UIImage?
if options.includeMapSnapshot {
mapSnapshot = await generateMapSnapshot(
visited: progress.stadiumsVisited,
remaining: progress.stadiumsRemaining
)
}
// Render SwiftUI view to image
let cardView = ProgressCardView(
progress: progress,
options: options,
mapSnapshot: mapSnapshot
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0 // High resolution
guard let image = renderer.uiImage else {
throw CardGeneratorError.renderingFailed
}
return image
}
/// Generate a map snapshot showing visited/unvisited stadiums
/// - Parameters:
/// - visited: Stadiums that have been visited
/// - remaining: Stadiums not yet visited
/// - Returns: The map snapshot image
func generateMapSnapshot(
visited: [Stadium],
remaining: [Stadium]
) async -> UIImage? {
let allStadiums = visited + remaining
guard !allStadiums.isEmpty else { return nil }
// Calculate region to show all stadiums
let coordinates = allStadiums.map {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
}
let minLat = coordinates.map(\.latitude).min() ?? 0
let maxLat = coordinates.map(\.latitude).max() ?? 0
let minLon = coordinates.map(\.longitude).min() ?? 0
let maxLon = coordinates.map(\.longitude).max() ?? 0
let center = CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
)
let span = MKCoordinateSpan(
latitudeDelta: (maxLat - minLat) * 1.3,
longitudeDelta: (maxLon - minLon) * 1.3
)
let region = MKCoordinateRegion(center: center, span: span)
// Create snapshot options
let options = MKMapSnapshotter.Options()
options.region = region
options.size = Self.mapSnapshotSize
options.mapType = .mutedStandard
let snapshotter = MKMapSnapshotter(options: options)
do {
let snapshot = try await snapshotter.start()
// Draw annotations on snapshot
let image = UIGraphicsImageRenderer(size: Self.mapSnapshotSize).image { context in
snapshot.image.draw(at: .zero)
// Draw stadium markers
for stadium in remaining {
let point = snapshot.point(for: CLLocationCoordinate2D(
latitude: stadium.latitude,
longitude: stadium.longitude
))
drawMarker(at: point, color: .gray, context: context.cgContext)
}
for stadium in visited {
let point = snapshot.point(for: CLLocationCoordinate2D(
latitude: stadium.latitude,
longitude: stadium.longitude
))
drawMarker(at: point, color: UIColor(Theme.warmOrange), context: context.cgContext)
}
}
return image
} catch {
return nil
}
}
private func drawMarker(at point: CGPoint, color: UIColor, context: CGContext) {
let markerSize: CGFloat = 16
context.setFillColor(color.cgColor)
context.fillEllipse(in: CGRect(
x: point.x - markerSize / 2,
y: point.y - markerSize / 2,
width: markerSize,
height: markerSize
))
// White border
context.setStrokeColor(UIColor.white.cgColor)
context.setLineWidth(2)
context.strokeEllipse(in: CGRect(
x: point.x - markerSize / 2,
y: point.y - markerSize / 2,
width: markerSize,
height: markerSize
))
}
}
// MARK: - Card Generator Errors
enum CardGeneratorError: Error, LocalizedError {
case renderingFailed
case mapSnapshotFailed
var errorDescription: String? {
switch self {
case .renderingFailed:
return "Failed to render progress card"
case .mapSnapshotFailed:
return "Failed to generate map snapshot"
}
}
}
// MARK: - Progress Card View
struct ProgressCardView: View {
let progress: LeagueProgress
let options: ProgressCardOptions
let mapSnapshot: UIImage?
var body: some View {
ZStack {
// Background gradient
LinearGradient(
colors: options.cardStyle == .dark
? [Color(hex: "1A1A2E"), Color(hex: "16213E")]
: [Color.white, Color(hex: "F5F5F5")],
startPoint: .top,
endPoint: .bottom
)
VStack(spacing: 40) {
// App logo and title
headerSection
Spacer()
// Progress ring
progressRingSection
// Stats row
if options.includeStats {
statsSection
}
// Map snapshot
if options.includeMapSnapshot, let snapshot = mapSnapshot {
mapSection(image: snapshot)
}
Spacer()
// Username if included
if options.includeUsername, let username = options.username, !username.isEmpty {
usernameSection(username)
}
// App branding footer
footerSection
}
.padding(60)
}
.frame(width: 1080, height: 1920)
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 16) {
// Sport icon
ZStack {
Circle()
.fill(progress.sport.themeColor.opacity(0.2))
.frame(width: 80, height: 80)
Image(systemName: progress.sport.iconName)
.font(.system(size: 40))
.foregroundStyle(progress.sport.themeColor)
}
Text("\(progress.sport.displayName) Stadium Quest")
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(options.cardStyle.textColor)
}
}
// MARK: - Progress Ring
private var progressRingSection: some View {
ZStack {
// Background ring
Circle()
.stroke(Theme.warmOrange.opacity(0.2), lineWidth: 24)
.frame(width: 320, height: 320)
// Progress ring
Circle()
.trim(from: 0, to: progress.completionPercentage / 100)
.stroke(
Theme.warmOrange,
style: StrokeStyle(lineWidth: 24, lineCap: .round)
)
.frame(width: 320, height: 320)
.rotationEffect(.degrees(-90))
// Center content
VStack(spacing: 8) {
Text("\(progress.visitedStadiums)")
.font(.system(size: 96, weight: .bold, design: .rounded))
.foregroundStyle(options.cardStyle.textColor)
Text("of \(progress.totalStadiums)")
.font(.system(size: 32, weight: .medium))
.foregroundStyle(options.cardStyle.secondaryTextColor)
Text("Stadiums Visited")
.font(.system(size: 24))
.foregroundStyle(options.cardStyle.secondaryTextColor)
}
}
}
// MARK: - Stats
private var statsSection: some View {
HStack(spacing: 60) {
statItem(value: "\(progress.visitedStadiums)", label: "Visited")
statItem(value: "\(progress.totalStadiums - progress.visitedStadiums)", label: "Remaining")
statItem(value: String(format: "%.0f%%", progress.completionPercentage), label: "Complete")
}
.padding(.vertical, 30)
.padding(.horizontal, 40)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(options.cardStyle == .dark
? Color.white.opacity(0.05)
: Color.black.opacity(0.05))
)
}
private func statItem(value: String, label: String) -> some View {
VStack(spacing: 8) {
Text(value)
.font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundStyle(Theme.warmOrange)
Text(label)
.font(.system(size: 20))
.foregroundStyle(options.cardStyle.secondaryTextColor)
}
}
// MARK: - Map
private func mapSection(image: UIImage) -> some View {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 960)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(Theme.warmOrange.opacity(0.3), lineWidth: 2)
}
}
// MARK: - Username
private func usernameSection(_ username: String) -> some View {
HStack(spacing: 12) {
Image(systemName: "person.circle.fill")
.font(.system(size: 24))
Text(username)
.font(.system(size: 28, weight: .medium))
}
.foregroundStyle(options.cardStyle.secondaryTextColor)
}
// MARK: - Footer
private var footerSection: some View {
VStack(spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "sportscourt.fill")
.font(.system(size: 20))
Text("SportsTime")
.font(.system(size: 24, weight: .semibold))
}
.foregroundStyle(Theme.warmOrange)
Text("Track your stadium adventures")
.font(.system(size: 18))
.foregroundStyle(options.cardStyle.secondaryTextColor)
}
}
}
// MARK: - Progress Share View
struct ProgressShareView: View {
let progress: LeagueProgress
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@State private var generatedImage: UIImage?
@State private var isGenerating = false
@State private var showShareSheet = false
@State private var error: String?
@State private var includeUsername = true
@State private var username = ""
@State private var includeMap = true
@State private var cardStyle: ProgressCardOptions.CardStyle = .dark
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Preview card
previewCard
.padding(.horizontal)
// Options
optionsSection
// Generate button
generateButton
.padding(.horizontal)
}
.padding(.vertical)
}
.navigationTitle("Share Progress")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.sheet(isPresented: $showShareSheet) {
if let image = generatedImage {
ShareSheet(items: [image])
}
}
.alert("Error", isPresented: .constant(error != nil)) {
Button("OK") { error = nil }
} message: {
Text(error ?? "")
}
}
}
private var previewCard: some View {
VStack(spacing: Theme.Spacing.md) {
Text("Preview")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
// Mini preview
ZStack {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(cardStyle == .dark
? Color(hex: "1A1A2E")
: Color.white)
.aspectRatio(9/16, contentMode: .fit)
.frame(maxHeight: 300)
VStack(spacing: 12) {
// Sport badge
HStack(spacing: 4) {
Image(systemName: progress.sport.iconName)
Text(progress.sport.displayName)
}
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(progress.sport.themeColor)
// Progress ring
ZStack {
Circle()
.stroke(Theme.warmOrange.opacity(0.2), lineWidth: 4)
.frame(width: 60, height: 60)
Circle()
.trim(from: 0, to: progress.completionPercentage / 100)
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.frame(width: 60, height: 60)
.rotationEffect(.degrees(-90))
VStack(spacing: 0) {
Text("\(progress.visitedStadiums)")
.font(.system(size: 18, weight: .bold))
Text("/\(progress.totalStadiums)")
.font(.system(size: 10))
}
.foregroundStyle(cardStyle == .dark ? .white : .black)
}
if includeMap {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
.frame(height: 40)
.overlay {
Image(systemName: "map")
.foregroundStyle(Color.gray)
}
}
if includeUsername && !username.isEmpty {
Text("@\(username)")
.font(.system(size: 10))
.foregroundStyle(cardStyle == .dark ? Color.gray : Color.gray)
}
// Branding
HStack(spacing: 4) {
Image(systemName: "sportscourt.fill")
Text("SportsTime")
}
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Theme.warmOrange)
}
.padding()
}
}
}
private var optionsSection: some View {
VStack(spacing: Theme.Spacing.md) {
// Style selector
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Style")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
HStack(spacing: Theme.Spacing.sm) {
styleButton(style: .dark, label: "Dark")
styleButton(style: .light, label: "Light")
}
}
.padding(.horizontal)
// Username toggle
Toggle(isOn: $includeUsername) {
Text("Include Username")
.font(.body)
}
.padding(.horizontal)
if includeUsername {
TextField("Username", text: $username)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
}
// Map toggle
Toggle(isOn: $includeMap) {
Text("Include Map")
.font(.body)
}
.padding(.horizontal)
}
.padding(.vertical)
.background(Theme.cardBackground(colorScheme))
}
private func styleButton(style: ProgressCardOptions.CardStyle, label: String) -> some View {
Button {
withAnimation { cardStyle = style }
} label: {
Text(label)
.font(.subheadline)
.foregroundStyle(cardStyle == style ? .white : Theme.textPrimary(colorScheme))
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm)
.background(cardStyle == style ? Theme.warmOrange : Theme.cardBackgroundElevated(colorScheme))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private var generateButton: some View {
Button {
generateCard()
} label: {
HStack {
if isGenerating {
LoadingSpinner(size: .small)
.colorScheme(.dark)
} else {
Image(systemName: "square.and.arrow.up")
}
Text(isGenerating ? "Generating..." : "Generate & Share")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.warmOrange)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(isGenerating)
}
private func generateCard() {
isGenerating = true
Task {
let options = ProgressCardOptions(
includeUsername: includeUsername,
username: username,
includeMapSnapshot: includeMap,
includeStats: true,
cardStyle: cardStyle
)
let generator = ProgressCardGenerator()
do {
generatedImage = try await generator.generateCard(
progress: progress,
options: options
)
showShareSheet = true
} catch {
self.error = error.localizedDescription
}
isGenerating = false
}
}
}
// MARK: - Preview
#Preview {
ProgressShareView(progress: LeagueProgress(
sport: .mlb,
totalStadiums: 30,
visitedStadiums: 12,
stadiumsVisited: [],
stadiumsRemaining: []
))
}