- PaywallView: remove unnecessary nil coalescing for currencyCode - GameDAGRouter: change var to let for immutable compositeKeys - GamesHistoryRow/View: add missing wnba and nwsl switch cases - VisitDetailView: fix unused variable in preview - AchievementEngine: use convenience init to avoid default parameter warning - ProgressCardGenerator: use method overload instead of default parameter - StadiumProximityMatcher: extract constants to ProximityConstants enum Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
607 lines
19 KiB
Swift
607 lines
19 KiB
Swift
//
|
|
// 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: []
|
|
))
|
|
}
|