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