// // 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 /// - Parameters: /// - progress: The league progress data /// - options: Card generation options /// - Returns: The generated UIImage func generateCard( progress: LeagueProgress, options: ProgressCardOptions = 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 { ThemedSpinnerCompact(size: 18, color: .white) } 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: [] )) }