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: []
))
}

View File

@@ -0,0 +1,423 @@
//
// AchievementCardGenerator.swift
// SportsTime
//
// Generates shareable achievement cards: spotlight, collection, milestone, context.
//
import SwiftUI
import UIKit
// MARK: - Achievement Spotlight Content
struct AchievementSpotlightContent: ShareableContent {
let achievement: AchievementProgress
var cardType: ShareCardType { .achievementSpotlight }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementSpotlightView(
achievement: achievement,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
// MARK: - Achievement Collection Content
struct AchievementCollectionContent: ShareableContent {
let achievements: [AchievementProgress]
let year: Int
var cardType: ShareCardType { .achievementCollection }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementCollectionView(
achievements: achievements,
year: year,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
// MARK: - Achievement Milestone Content
struct AchievementMilestoneContent: ShareableContent {
let achievement: AchievementProgress
var cardType: ShareCardType { .achievementMilestone }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementMilestoneView(
achievement: achievement,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
// MARK: - Achievement Context Content
struct AchievementContextContent: ShareableContent {
let achievement: AchievementProgress
let tripName: String?
let mapSnapshot: UIImage?
var cardType: ShareCardType { .achievementContext }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementContextView(
achievement: achievement,
tripName: tripName,
mapSnapshot: mapSnapshot,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
// MARK: - Spotlight View
private struct AchievementSpotlightView: View {
let achievement: AchievementProgress
let theme: ShareTheme
var body: some View {
ZStack {
ShareCardBackground(theme: theme)
VStack(spacing: 50) {
Spacer()
// Badge
AchievementBadge(
definition: achievement.definition,
size: 400
)
// Name
Text(achievement.definition.name)
.font(.system(size: 56, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
// Description
Text(achievement.definition.description)
.font(.system(size: 28))
.foregroundStyle(theme.secondaryTextColor)
.multilineTextAlignment(.center)
.padding(.horizontal, 80)
// Unlock date
if let earnedAt = achievement.earnedAt {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(theme.accentColor)
Text("Unlocked \(earnedAt.formatted(date: .abbreviated, time: .omitted))")
}
.font(.system(size: 24))
.foregroundStyle(theme.secondaryTextColor)
}
Spacer()
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
}
}
// MARK: - Collection View
private struct AchievementCollectionView: View {
let achievements: [AchievementProgress]
let year: Int
let theme: ShareTheme
private let columns = [
GridItem(.flexible(), spacing: 30),
GridItem(.flexible(), spacing: 30),
GridItem(.flexible(), spacing: 30)
]
var body: some View {
ZStack {
ShareCardBackground(theme: theme)
VStack(spacing: 40) {
// Header
Text("My \(year) Achievements")
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
Spacer()
// Grid
LazyVGrid(columns: columns, spacing: 40) {
ForEach(achievements.prefix(12)) { achievement in
VStack(spacing: 12) {
AchievementBadge(
definition: achievement.definition,
size: 200
)
Text(achievement.definition.name)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(theme.textColor)
.lineLimit(2)
.multilineTextAlignment(.center)
}
}
}
.padding(.horizontal, 40)
Spacer()
// Count
Text("\(achievements.count) achievements unlocked")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
}
}
// MARK: - Milestone View
private struct AchievementMilestoneView: View {
let achievement: AchievementProgress
let theme: ShareTheme
private let goldColor = Color(hex: "FFD700")
var body: some View {
ZStack {
ShareCardBackground(theme: theme)
// Confetti burst pattern
ConfettiBurst()
.opacity(0.3)
VStack(spacing: 40) {
Spacer()
// Milestone label
Text("MILESTONE")
.font(.system(size: 24, weight: .black, design: .rounded))
.tracking(4)
.foregroundStyle(goldColor)
// Large badge
AchievementBadge(
definition: achievement.definition,
size: 500
)
.overlay {
Circle()
.stroke(goldColor, lineWidth: 4)
.frame(width: 520, height: 520)
}
// Name
Text(achievement.definition.name)
.font(.system(size: 56, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
// Description
Text(achievement.definition.description)
.font(.system(size: 28))
.foregroundStyle(theme.secondaryTextColor)
.multilineTextAlignment(.center)
.padding(.horizontal, 80)
Spacer()
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
}
}
// MARK: - Context View
private struct AchievementContextView: View {
let achievement: AchievementProgress
let tripName: String?
let mapSnapshot: UIImage?
let theme: ShareTheme
var body: some View {
ZStack {
ShareCardBackground(theme: theme)
VStack(spacing: 40) {
// Header with badge and name
HStack(spacing: 24) {
AchievementBadge(
definition: achievement.definition,
size: 150
)
VStack(alignment: .leading, spacing: 8) {
Text(achievement.definition.name)
.font(.system(size: 40, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
Text("Unlocked!")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(theme.accentColor)
}
}
.padding(.top, 40)
Spacer()
// Context map or placeholder
if let snapshot = mapSnapshot {
Image(uiImage: snapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 960, maxHeight: 700)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(theme.accentColor.opacity(0.3), lineWidth: 2)
}
}
// Trip name
if let tripName = tripName {
Text("Unlocked during my")
.font(.system(size: 24))
.foregroundStyle(theme.secondaryTextColor)
Text(tripName)
.font(.system(size: 32, weight: .semibold))
.foregroundStyle(theme.textColor)
}
Spacer()
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
}
}
// MARK: - Achievement Badge
private struct AchievementBadge: View {
let definition: AchievementDefinition
let size: CGFloat
var body: some View {
ZStack {
Circle()
.fill(definition.iconColor.opacity(0.2))
.frame(width: size, height: size)
Circle()
.stroke(definition.iconColor, lineWidth: size * 0.02)
.frame(width: size * 0.9, height: size * 0.9)
Image(systemName: definition.iconName)
.font(.system(size: size * 0.4))
.foregroundStyle(definition.iconColor)
}
}
}
// MARK: - Confetti Burst
private struct ConfettiBurst: View {
var body: some View {
GeometryReader { geometry in
let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height * 0.4)
ForEach(0..<24, id: \.self) { index in
let angle = Double(index) * (360.0 / 24.0)
let distance: CGFloat = CGFloat.random(in: 200...400)
let xOffset = cos(angle * .pi / 180) * distance
let yOffset = sin(angle * .pi / 180) * distance
Circle()
.fill(confettiColor(for: index))
.frame(width: CGFloat.random(in: 8...20))
.position(
x: center.x + xOffset,
y: center.y + yOffset
)
}
}
}
private func confettiColor(for index: Int) -> Color {
let colors: [Color] = [
Color(hex: "FFD700"),
Color(hex: "FF6B35"),
Color(hex: "00D4FF"),
Color(hex: "95D5B2"),
Color(hex: "FF85A1")
]
return colors[index % colors.count]
}
}

View File

@@ -0,0 +1,115 @@
//
// ProgressCardGenerator.swift
// SportsTime
//
// Generates shareable stadium progress cards.
//
import SwiftUI
import UIKit
// MARK: - Progress Share Content
struct ProgressShareContent: ShareableContent {
let progress: LeagueProgress
let tripCount: Int
let username: String?
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 cardView = ProgressCardView(
progress: progress,
tripCount: tripCount,
username: username,
theme: theme,
mapSnapshot: mapSnapshot
)
let renderer = ImageRenderer(content: cardView)
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 username: String?
let theme: ShareTheme
let mapSnapshot: UIImage?
var body: some View {
ZStack {
ShareCardBackground(theme: theme)
VStack(spacing: 40) {
ShareCardHeader(
title: "\(progress.sport.displayName) Stadium Quest",
sport: progress.sport,
theme: theme
)
Spacer()
// Progress ring
ShareProgressRing(
current: progress.visitedStadiums,
total: progress.totalStadiums,
theme: theme
)
Text("\(Int(progress.completionPercentage))% Complete")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
// Stats row
ShareStatsRow(
stats: [
(value: "\(progress.visitedStadiums)", label: "visited"),
(value: "\(progress.totalStadiums - progress.visitedStadiums)", label: "remain"),
(value: "\(tripCount)", label: "trips")
],
theme: theme
)
// Map
if let snapshot = mapSnapshot {
Image(uiImage: snapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 960)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(theme.accentColor.opacity(0.3), lineWidth: 2)
}
}
Spacer()
ShareCardFooter(theme: theme, username: username)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
}
}

View File

@@ -0,0 +1,412 @@
//
// ShareCardComponents.swift
// SportsTime
//
// Reusable components for share cards: header, footer, stats row, map snapshot.
//
import SwiftUI
import MapKit
import UIKit
// MARK: - Card Background
struct ShareCardBackground: View {
let theme: ShareTheme
var body: some View {
LinearGradient(
colors: theme.gradientColors,
startPoint: .top,
endPoint: .bottom
)
}
}
// MARK: - Card Header
struct ShareCardHeader: View {
let title: String
let sport: Sport?
let theme: ShareTheme
var body: some View {
VStack(spacing: 16) {
if let sport = sport {
ZStack {
Circle()
.fill(theme.accentColor.opacity(0.2))
.frame(width: 80, height: 80)
Image(systemName: sport.iconName)
.font(.system(size: 40))
.foregroundStyle(theme.accentColor)
}
}
Text(title)
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
}
}
}
// MARK: - Card Footer
struct ShareCardFooter: View {
let theme: ShareTheme
var username: String? = nil
var body: some View {
VStack(spacing: 12) {
if let username = username, !username.isEmpty {
HStack(spacing: 8) {
Image(systemName: "person.circle.fill")
.font(.system(size: 24))
Text("@\(username)")
.font(.system(size: 28, weight: .medium))
}
.foregroundStyle(theme.secondaryTextColor)
}
HStack(spacing: 8) {
Image(systemName: "sportscourt.fill")
.font(.system(size: 20))
Text("SportsTime")
.font(.system(size: 24, weight: .semibold))
}
.foregroundStyle(theme.accentColor)
Text("Plan your stadium adventure")
.font(.system(size: 18))
.foregroundStyle(theme.secondaryTextColor)
}
}
}
// MARK: - Stats Row
struct ShareStatsRow: View {
let stats: [(value: String, label: String)]
let theme: ShareTheme
var body: some View {
HStack(spacing: 60) {
ForEach(Array(stats.enumerated()), id: \.offset) { _, stat in
VStack(spacing: 8) {
Text(stat.value)
.font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundStyle(theme.accentColor)
Text(stat.label)
.font(.system(size: 20))
.foregroundStyle(theme.secondaryTextColor)
}
}
}
.padding(.vertical, 30)
.padding(.horizontal, 40)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(theme.textColor.opacity(0.05))
)
}
}
// MARK: - Progress Ring
struct ShareProgressRing: View {
let current: Int
let total: Int
let theme: ShareTheme
var size: CGFloat = 320
var lineWidth: CGFloat = 24
private var progress: Double {
guard total > 0 else { return 0 }
return Double(current) / Double(total)
}
var body: some View {
ZStack {
// Background ring
Circle()
.stroke(theme.accentColor.opacity(0.2), lineWidth: lineWidth)
.frame(width: size, height: size)
// Progress ring
Circle()
.trim(from: 0, to: progress)
.stroke(
theme.accentColor,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.frame(width: size, height: size)
.rotationEffect(.degrees(-90))
// Center content
VStack(spacing: 8) {
Text("\(current)")
.font(.system(size: 96, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
Text("of \(total)")
.font(.system(size: 32, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
}
}
}
}
// MARK: - Map Snapshot Generator
@MainActor
final class ShareMapSnapshotGenerator {
/// Generate a progress map showing visited/remaining stadiums
func generateProgressMap(
visited: [Stadium],
remaining: [Stadium],
theme: ShareTheme
) async -> UIImage? {
let allStadiums = visited + remaining
guard !allStadiums.isEmpty else { return nil }
let region = calculateRegion(for: allStadiums)
let options = MKMapSnapshotter.Options()
options.region = region
options.size = ShareCardDimensions.mapSnapshotSize
options.mapType = theme.useDarkMap ? .mutedStandard : .standard
let snapshotter = MKMapSnapshotter(options: options)
do {
let snapshot = try await snapshotter.start()
return drawStadiumMarkers(
on: snapshot,
visited: visited,
remaining: remaining,
accentColor: UIColor(theme.accentColor)
)
} catch {
return nil
}
}
/// Generate a route map for trip cards
func generateRouteMap(
stops: [TripStop],
theme: ShareTheme
) async -> UIImage? {
let stopsWithCoordinates = stops.filter { $0.coordinate != nil }
guard stopsWithCoordinates.count >= 2 else { return nil }
let coordinates = stopsWithCoordinates.compactMap { $0.coordinate }
let region = calculateRegion(for: coordinates)
let options = MKMapSnapshotter.Options()
options.region = region
options.size = ShareCardDimensions.routeMapSize
options.mapType = theme.useDarkMap ? .mutedStandard : .standard
let snapshotter = MKMapSnapshotter(options: options)
do {
let snapshot = try await snapshotter.start()
return drawRoute(
on: snapshot,
stops: stopsWithCoordinates,
accentColor: UIColor(theme.accentColor)
)
} catch {
return nil
}
}
// MARK: - Private Helpers
private func calculateRegion(for stadiums: [Stadium]) -> MKCoordinateRegion {
let coordinates = stadiums.map {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
}
return calculateRegion(for: coordinates)
}
private func calculateRegion(for coordinates: [CLLocationCoordinate2D]) -> MKCoordinateRegion {
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: max((maxLat - minLat) * 1.4, 1),
longitudeDelta: max((maxLon - minLon) * 1.4, 1)
)
return MKCoordinateRegion(center: center, span: span)
}
private func drawStadiumMarkers(
on snapshot: MKMapSnapshotter.Snapshot,
visited: [Stadium],
remaining: [Stadium],
accentColor: UIColor
) -> UIImage {
let size = ShareCardDimensions.mapSnapshotSize
return UIGraphicsImageRenderer(size: size).image { context in
snapshot.image.draw(at: .zero)
// Draw remaining (gray) first
for stadium in remaining {
let point = snapshot.point(for: CLLocationCoordinate2D(
latitude: stadium.latitude,
longitude: stadium.longitude
))
drawMarker(at: point, color: .gray, context: context.cgContext)
}
// Draw visited (accent) on top
for stadium in visited {
let point = snapshot.point(for: CLLocationCoordinate2D(
latitude: stadium.latitude,
longitude: stadium.longitude
))
drawMarker(at: point, color: accentColor, context: context.cgContext)
}
}
}
private func drawRoute(
on snapshot: MKMapSnapshotter.Snapshot,
stops: [TripStop],
accentColor: UIColor
) -> UIImage {
let size = ShareCardDimensions.routeMapSize
return UIGraphicsImageRenderer(size: size).image { context in
snapshot.image.draw(at: .zero)
let cgContext = context.cgContext
// Draw route line
cgContext.setStrokeColor(accentColor.cgColor)
cgContext.setLineWidth(4)
cgContext.setLineCap(.round)
cgContext.setLineJoin(.round)
let points = stops.compactMap { stop -> CGPoint? in
guard let coord = stop.coordinate else { return nil }
return snapshot.point(for: coord)
}
if let first = points.first {
cgContext.move(to: first)
for point in points.dropFirst() {
cgContext.addLine(to: point)
}
cgContext.strokePath()
}
// Draw city markers
for (index, stop) in stops.enumerated() {
guard let coord = stop.coordinate else { continue }
let point = snapshot.point(for: coord)
drawCityMarker(
at: point,
label: String(stop.city.prefix(3)).uppercased(),
isFirst: index == 0,
isLast: index == stops.count - 1,
color: accentColor,
context: cgContext
)
}
}
}
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
))
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
))
}
private func drawCityMarker(
at point: CGPoint,
label: String,
isFirst: Bool,
isLast: Bool,
color: UIColor,
context: CGContext
) {
let markerSize: CGFloat = isFirst || isLast ? 24 : 18
// Outer circle
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(3)
context.strokeEllipse(in: CGRect(
x: point.x - markerSize / 2,
y: point.y - markerSize / 2,
width: markerSize,
height: markerSize
))
// Label above marker
let labelRect = CGRect(
x: point.x - 30,
y: point.y - markerSize / 2 - 22,
width: 60,
height: 20
)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 12, weight: .bold),
.foregroundColor: UIColor.white,
.paragraphStyle: paragraphStyle
]
// Draw label background
let labelBgRect = CGRect(
x: point.x - 22,
y: point.y - markerSize / 2 - 24,
width: 44,
height: 18
)
context.setFillColor(color.withAlphaComponent(0.9).cgColor)
let path = UIBezierPath(roundedRect: labelBgRect, cornerRadius: 4)
context.addPath(path.cgPath)
context.fillPath()
label.draw(in: labelRect, withAttributes: attributes)
}
}

View File

@@ -0,0 +1,121 @@
//
// ShareService.swift
// SportsTime
//
// Handles Instagram direct share and fallback to system share sheet.
//
import SwiftUI
import UIKit
@MainActor
final class ShareService {
static let shared = ShareService()
private init() {}
// MARK: - Share to Instagram
func shareToInstagram(image: UIImage) -> Bool {
guard let imageData = image.pngData() else { return false }
// Check if Instagram is installed
guard let instagramURL = URL(string: "instagram-stories://share"),
UIApplication.shared.canOpenURL(instagramURL) else {
return false
}
// Set up pasteboard with image
let pasteboardItems: [String: Any] = [
"com.instagram.sharedSticker.backgroundImage": imageData
]
UIPasteboard.general.setItems(
[pasteboardItems],
options: [.expirationDate: Date().addingTimeInterval(300)]
)
// Open Instagram Stories
let urlString = "instagram-stories://share?source_application=com.sportstime.app"
if let url = URL(string: urlString) {
UIApplication.shared.open(url)
return true
}
return false
}
// MARK: - Copy to Clipboard
func copyToClipboard(image: UIImage) {
UIPasteboard.general.image = image
}
// MARK: - System Share Sheet
func presentShareSheet(image: UIImage, from viewController: UIViewController) {
let activityVC = UIActivityViewController(
activityItems: [image],
applicationActivities: nil
)
// iPad support
if let popover = activityVC.popoverPresentationController {
popover.sourceView = viewController.view
popover.sourceRect = CGRect(
x: viewController.view.bounds.midX,
y: viewController.view.bounds.midY,
width: 0,
height: 0
)
}
viewController.present(activityVC, animated: true)
}
}
// MARK: - Theme Persistence
enum ShareThemePreferences {
private static let tripKey = "shareTheme.trip"
private static let achievementKey = "shareTheme.achievement"
private static let progressKey = "shareTheme.progress"
static var tripTheme: ShareTheme {
get { ShareTheme.theme(byId: UserDefaults.standard.string(forKey: tripKey) ?? "dark") }
set { UserDefaults.standard.set(newValue.id, forKey: tripKey) }
}
static var achievementTheme: ShareTheme {
get { ShareTheme.theme(byId: UserDefaults.standard.string(forKey: achievementKey) ?? "dark") }
set { UserDefaults.standard.set(newValue.id, forKey: achievementKey) }
}
static var progressTheme: ShareTheme {
get { ShareTheme.theme(byId: UserDefaults.standard.string(forKey: progressKey) ?? "dark") }
set { UserDefaults.standard.set(newValue.id, forKey: progressKey) }
}
static func theme(for cardType: ShareCardType) -> ShareTheme {
switch cardType {
case .tripSummary:
return tripTheme
case .achievementSpotlight, .achievementCollection, .achievementMilestone, .achievementContext:
return achievementTheme
case .stadiumProgress:
return progressTheme
}
}
static func setTheme(_ theme: ShareTheme, for cardType: ShareCardType) {
switch cardType {
case .tripSummary:
tripTheme = theme
case .achievementSpotlight, .achievementCollection, .achievementMilestone, .achievementContext:
achievementTheme = theme
case .stadiumProgress:
progressTheme = theme
}
}
}

View File

@@ -0,0 +1,157 @@
//
// ShareableContent.swift
// SportsTime
//
// Protocol for shareable content and theme definitions.
//
import SwiftUI
import UIKit
// MARK: - Shareable Content Protocol
protocol ShareableContent {
var cardType: ShareCardType { get }
func render(theme: ShareTheme) async throws -> UIImage
}
// MARK: - Card Types
enum ShareCardType: String, CaseIterable {
case tripSummary
case achievementSpotlight
case achievementCollection
case achievementMilestone
case achievementContext
case stadiumProgress
}
// MARK: - Share Theme
struct ShareTheme: Identifiable, Hashable {
let id: String
let name: String
let gradientColors: [Color]
let accentColor: Color
let textColor: Color
let secondaryTextColor: Color
let useDarkMap: Bool
// MARK: - Preset Themes
static let dark = ShareTheme(
id: "dark",
name: "Dark",
gradientColors: [Color(hex: "1A1A2E"), Color(hex: "16213E")],
accentColor: Color(hex: "FF6B35"),
textColor: .white,
secondaryTextColor: Color(hex: "B8B8D1"),
useDarkMap: true
)
static let light = ShareTheme(
id: "light",
name: "Light",
gradientColors: [.white, Color(hex: "F5F5F5")],
accentColor: Color(hex: "FF6B35"),
textColor: Color(hex: "1A1A2E"),
secondaryTextColor: Color(hex: "666666"),
useDarkMap: false
)
static let midnight = ShareTheme(
id: "midnight",
name: "Midnight",
gradientColors: [Color(hex: "0D1B2A"), Color(hex: "1B263B")],
accentColor: Color(hex: "00D4FF"),
textColor: .white,
secondaryTextColor: Color(hex: "A0AEC0"),
useDarkMap: true
)
static let forest = ShareTheme(
id: "forest",
name: "Forest",
gradientColors: [Color(hex: "1B4332"), Color(hex: "2D6A4F")],
accentColor: Color(hex: "95D5B2"),
textColor: .white,
secondaryTextColor: Color(hex: "B7E4C7"),
useDarkMap: false
)
static let sunset = ShareTheme(
id: "sunset",
name: "Sunset",
gradientColors: [Color(hex: "FF6B35"), Color(hex: "F7931E")],
accentColor: .white,
textColor: .white,
secondaryTextColor: Color(hex: "FFE5D9"),
useDarkMap: false
)
static let berry = ShareTheme(
id: "berry",
name: "Berry",
gradientColors: [Color(hex: "4A0E4E"), Color(hex: "81267E")],
accentColor: Color(hex: "FF85A1"),
textColor: .white,
secondaryTextColor: Color(hex: "E0B0FF"),
useDarkMap: true
)
static let ocean = ShareTheme(
id: "ocean",
name: "Ocean",
gradientColors: [Color(hex: "023E8A"), Color(hex: "0077B6")],
accentColor: Color(hex: "90E0EF"),
textColor: .white,
secondaryTextColor: Color(hex: "CAF0F8"),
useDarkMap: true
)
static let slate = ShareTheme(
id: "slate",
name: "Slate",
gradientColors: [Color(hex: "2B2D42"), Color(hex: "3D405B")],
accentColor: Color(hex: "F4A261"),
textColor: Color(hex: "EDF2F4"),
secondaryTextColor: Color(hex: "8D99AE"),
useDarkMap: true
)
static let all: [ShareTheme] = [.dark, .light, .midnight, .forest, .sunset, .berry, .ocean, .slate]
static func theme(byId id: String) -> ShareTheme {
all.first { $0.id == id } ?? .dark
}
}
// MARK: - Share Errors
enum ShareError: Error, LocalizedError {
case renderingFailed
case mapSnapshotFailed
case instagramNotInstalled
var errorDescription: String? {
switch self {
case .renderingFailed:
return "Failed to render share card"
case .mapSnapshotFailed:
return "Failed to generate map snapshot"
case .instagramNotInstalled:
return "Instagram is not installed"
}
}
}
// MARK: - Card Dimensions
enum ShareCardDimensions {
static let cardSize = CGSize(width: 1080, height: 1920)
static let mapSnapshotSize = CGSize(width: 1000, height: 500)
static let routeMapSize = CGSize(width: 1000, height: 600)
static let padding: CGFloat = 60
static let headerHeight: CGFloat = 120
static let footerHeight: CGFloat = 100
}

View File

@@ -0,0 +1,126 @@
//
// TripCardGenerator.swift
// SportsTime
//
// Generates shareable trip summary cards with route map.
//
import SwiftUI
import UIKit
// MARK: - Trip Share Content
struct TripShareContent: ShareableContent {
let trip: Trip
var cardType: ShareCardType { .tripSummary }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let mapGenerator = ShareMapSnapshotGenerator()
let mapSnapshot = await mapGenerator.generateRouteMap(
stops: trip.stops,
theme: theme
)
let cardView = TripCardView(
trip: trip,
theme: theme,
mapSnapshot: mapSnapshot
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
// MARK: - Trip Card View
private struct TripCardView: View {
let trip: Trip
let theme: ShareTheme
let mapSnapshot: UIImage?
private var sportTitle: String {
if trip.uniqueSports.count == 1, let sport = trip.uniqueSports.first {
return "My \(sport.displayName) Road Trip"
}
return "My Sports Road Trip"
}
private var primarySport: Sport? {
trip.uniqueSports.first
}
var body: some View {
ZStack {
ShareCardBackground(theme: theme)
VStack(spacing: 40) {
ShareCardHeader(
title: sportTitle,
sport: primarySport,
theme: theme
)
// Map
if let snapshot = mapSnapshot {
Image(uiImage: snapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 960, maxHeight: 600)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(theme.accentColor.opacity(0.3), lineWidth: 2)
}
}
// Date range
Text(trip.formattedDateRange)
.font(.system(size: 32, weight: .medium))
.foregroundStyle(theme.textColor)
// Stats row
ShareStatsRow(
stats: [
(value: String(format: "%.0f", trip.totalDistanceMiles), label: "miles"),
(value: "\(trip.totalGames)", label: "games"),
(value: "\(trip.cities.count)", label: "cities")
],
theme: theme
)
// City trail
cityTrail
Spacer()
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
}
private var cityTrail: some View {
let cities = trip.cities
let displayText = cities.joined(separator: "")
return Text(displayText)
.font(.system(size: 24, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
.multilineTextAlignment(.center)
.lineLimit(3)
.padding(.horizontal, 40)
}
}

View File

@@ -0,0 +1,71 @@
//
// ShareButton.swift
// SportsTime
//
// Contextual share button component.
//
import SwiftUI
struct ShareButton<Content: ShareableContent>: View {
let content: Content
var style: ShareButtonStyle = .icon
@State private var showPreview = false
var body: some View {
Button {
showPreview = true
} label: {
switch style {
case .icon:
Image(systemName: "square.and.arrow.up")
case .labeled:
Label("Share", systemImage: "square.and.arrow.up")
case .pill:
HStack(spacing: 4) {
Image(systemName: "square.and.arrow.up")
Text("Share")
}
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Theme.warmOrange)
.foregroundStyle(.white)
.clipShape(Capsule())
}
}
.sheet(isPresented: $showPreview) {
SharePreviewView(content: content)
}
}
}
enum ShareButtonStyle {
case icon
case labeled
case pill
}
// MARK: - Convenience Initializers
extension ShareButton where Content == TripShareContent {
init(trip: Trip, style: ShareButtonStyle = .icon) {
self.content = TripShareContent(trip: trip)
self.style = style
}
}
extension ShareButton where Content == ProgressShareContent {
init(progress: LeagueProgress, tripCount: Int = 0, username: String? = nil, style: ShareButtonStyle = .icon) {
self.content = ProgressShareContent(progress: progress, tripCount: tripCount, username: username)
self.style = style
}
}
extension ShareButton where Content == AchievementSpotlightContent {
init(achievement: AchievementProgress, style: ShareButtonStyle = .icon) {
self.content = AchievementSpotlightContent(achievement: achievement)
self.style = style
}
}

View File

@@ -0,0 +1,318 @@
//
// SharePreviewView.swift
// SportsTime
//
// Unified preview and customization UI for all shareable content.
//
import SwiftUI
struct SharePreviewView<Content: ShareableContent>: View {
let content: Content
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@State private var selectedTheme: ShareTheme
@State private var generatedImage: UIImage?
@State private var isGenerating = false
@State private var error: String?
@State private var showCopiedToast = false
// Progress-specific options
@State private var includeUsername = true
@State private var username = ""
init(content: Content) {
self.content = content
_selectedTheme = State(initialValue: ShareThemePreferences.theme(for: content.cardType))
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Preview
previewSection
// Theme selector
themeSelector
// Username toggle (progress cards only)
if content.cardType == .stadiumProgress {
usernameSection
}
// Action buttons
actionButtons
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.navigationTitle("Share")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.alert("Error", isPresented: .constant(error != nil)) {
Button("OK") { error = nil }
} message: {
Text(error ?? "")
}
.overlay {
if showCopiedToast {
copiedToast
}
}
.task {
await generatePreview()
}
.onChange(of: selectedTheme) { _, _ in
Task { await generatePreview() }
}
}
}
// MARK: - Preview Section
private var previewSection: some View {
VStack(spacing: Theme.Spacing.sm) {
Text("Preview")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
if let image = generatedImage {
Image(uiImage: image)
.resizable()
.aspectRatio(9/16, contentMode: .fit)
.frame(maxHeight: 400)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5)
} else if isGenerating {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(Theme.cardBackground(colorScheme))
.aspectRatio(9/16, contentMode: .fit)
.frame(maxHeight: 400)
.overlay {
ProgressView()
}
}
}
}
// MARK: - Theme Selector
private var themeSelector: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Theme")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.sm) {
ForEach(ShareTheme.all) { theme in
themeButton(theme)
}
}
}
}
}
private func themeButton(_ theme: ShareTheme) -> some View {
Button {
withAnimation {
selectedTheme = theme
ShareThemePreferences.setTheme(theme, for: content.cardType)
}
} label: {
VStack(spacing: 4) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
colors: theme.gradientColors,
startPoint: .top,
endPoint: .bottom
)
)
.frame(width: 50, height: 50)
Circle()
.fill(theme.accentColor)
.frame(width: 16, height: 16)
}
.overlay {
if selectedTheme.id == theme.id {
RoundedRectangle(cornerRadius: 8)
.stroke(Theme.warmOrange, lineWidth: 3)
}
}
Text(theme.name)
.font(.caption2)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
.buttonStyle(.plain)
}
// MARK: - Username Section
private var usernameSection: some View {
VStack(spacing: Theme.Spacing.sm) {
Toggle(isOn: $includeUsername) {
Text("Include username")
}
.onChange(of: includeUsername) { _, _ in
Task { await generatePreview() }
}
if includeUsername {
TextField("@username", text: $username)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.onChange(of: username) { _, _ in
Task { await generatePreview() }
}
}
}
.padding()
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
// MARK: - Action Buttons
private var actionButtons: some View {
VStack(spacing: Theme.Spacing.sm) {
// Primary: Share to Instagram
Button {
shareToInstagram()
} label: {
HStack {
Image(systemName: "camera.fill")
Text("Share to Instagram")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.warmOrange)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(generatedImage == nil)
HStack(spacing: Theme.Spacing.sm) {
// Copy Image
Button {
copyImage()
} label: {
HStack {
Image(systemName: "doc.on.doc")
Text("Copy")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.foregroundStyle(Theme.textPrimary(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(generatedImage == nil)
// More Options
Button {
showSystemShare()
} label: {
HStack {
Image(systemName: "ellipsis.circle")
Text("More")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.foregroundStyle(Theme.textPrimary(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(generatedImage == nil)
}
}
}
// MARK: - Copied Toast
private var copiedToast: some View {
VStack {
Spacer()
HStack {
Image(systemName: "checkmark.circle.fill")
Text("Copied to clipboard")
}
.padding()
.background(.ultraThinMaterial)
.clipShape(Capsule())
.padding(.bottom, 100)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// MARK: - Actions
private func generatePreview() async {
isGenerating = true
do {
// For progress content, we may need to inject username
if let progressContent = content as? ProgressShareContent {
// This is a workaround - ideally we'd have a more elegant solution
let modifiedContent = ProgressShareContent(
progress: progressContent.progress,
tripCount: progressContent.tripCount,
username: includeUsername ? (username.isEmpty ? nil : username) : nil
)
generatedImage = try await modifiedContent.render(theme: selectedTheme)
} else {
generatedImage = try await content.render(theme: selectedTheme)
}
} catch {
self.error = error.localizedDescription
}
isGenerating = false
}
private func shareToInstagram() {
guard let image = generatedImage else { return }
if !ShareService.shared.shareToInstagram(image: image) {
// Fallback to system share
showSystemShare()
}
}
private func copyImage() {
guard let image = generatedImage else { return }
ShareService.shared.copyToClipboard(image: image)
withAnimation {
showCopiedToast = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
showCopiedToast = false
}
}
}
private func showSystemShare() {
guard let image = generatedImage,
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController else { return }
ShareService.shared.presentShareSheet(image: image, from: rootVC)
}
}

View File

@@ -108,6 +108,12 @@ final class ProgressViewModel {
leagueProgress.stadiumsRemaining leagueProgress.stadiumsRemaining
} }
/// Count of trips for the selected sport (stub - can be enhanced)
var tripCount: Int {
// TODO: Fetch saved trips count from SwiftData
0
}
/// Recent visits sorted by date /// Recent visits sorted by date
var recentVisits: [VisitSummary] { var recentVisits: [VisitSummary] {
visits visits

View File

@@ -36,6 +36,20 @@ struct AchievementsListView: View {
} }
.themedBackground() .themedBackground()
.navigationTitle("Achievements") .navigationTitle("Achievements")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if !earnedAchievements.isEmpty {
ShareButton(
content: AchievementCollectionContent(
achievements: earnedAchievements,
year: Calendar.current.component(.year, from: Date())
),
style: .icon
)
.foregroundStyle(Theme.warmOrange)
}
}
}
.task { .task {
await loadAchievements() await loadAchievements()
} }
@@ -184,6 +198,10 @@ struct AchievementsListView: View {
} }
} }
private var earnedAchievements: [AchievementProgress] {
achievements.filter { $0.isEarned }
}
private var filteredAchievements: [AchievementProgress] { private var filteredAchievements: [AchievementProgress] {
let filtered: [AchievementProgress] let filtered: [AchievementProgress]

View File

@@ -15,7 +15,6 @@ struct ProgressTabView: View {
@State private var viewModel = ProgressViewModel() @State private var viewModel = ProgressViewModel()
@State private var showVisitSheet = false @State private var showVisitSheet = false
@State private var showPhotoImport = false @State private var showPhotoImport = false
@State private var showShareSheet = false
@State private var selectedStadium: Stadium? @State private var selectedStadium: Stadium?
@State private var selectedVisitId: UUID? @State private var selectedVisitId: UUID?
@@ -65,12 +64,12 @@ struct ProgressTabView: View {
.themedBackground() .themedBackground()
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Button { ShareButton(
showShareSheet = true progress: viewModel.leagueProgress,
} label: { tripCount: viewModel.tripCount,
Image(systemName: "square.and.arrow.up") style: .icon
.foregroundStyle(Theme.warmOrange) )
} .foregroundStyle(Theme.warmOrange)
} }
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
@@ -125,9 +124,6 @@ struct ProgressTabView: View {
) )
.presentationDetents([.medium]) .presentationDetents([.medium])
} }
.sheet(isPresented: $showShareSheet) {
ProgressShareView(progress: viewModel.leagueProgress)
}
} }
// MARK: - League Selector // MARK: - League Selector

View File

@@ -18,9 +18,7 @@ struct TripDetailView: View {
@State private var showProPaywall = false @State private var showProPaywall = false
@State private var selectedDay: ItineraryDay? @State private var selectedDay: ItineraryDay?
@State private var showExportSheet = false @State private var showExportSheet = false
@State private var showShareSheet = false
@State private var exportURL: URL? @State private var exportURL: URL?
@State private var shareURL: URL?
@State private var isExporting = false @State private var isExporting = false
@State private var exportProgress: PDFAssetPrefetcher.PrefetchProgress? @State private var exportProgress: PDFAssetPrefetcher.PrefetchProgress?
@State private var mapCameraPosition: MapCameraPosition = .automatic @State private var mapCameraPosition: MapCameraPosition = .automatic
@@ -63,12 +61,8 @@ struct TripDetailView: View {
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar) .toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .primaryAction) { ToolbarItemGroup(placement: .primaryAction) {
Button { ShareButton(trip: trip, style: .icon)
shareTrip() .foregroundStyle(Theme.warmOrange)
} label: {
Image(systemName: "square.and.arrow.up")
.foregroundStyle(Theme.warmOrange)
}
Button { Button {
if StoreManager.shared.isPro { if StoreManager.shared.isPro {
@@ -94,13 +88,6 @@ struct TripDetailView: View {
ShareSheet(items: [url]) ShareSheet(items: [url])
} }
} }
.sheet(isPresented: $showShareSheet) {
if let url = shareURL {
ShareSheet(items: [url])
} else {
ShareSheet(items: [trip.name, trip.formattedDateRange])
}
}
.sheet(isPresented: $showProPaywall) { .sheet(isPresented: $showProPaywall) {
PaywallView() PaywallView()
} }
@@ -523,11 +510,6 @@ struct TripDetailView: View {
isExporting = false isExporting = false
} }
private func shareTrip() {
shareURL = exportService.shareTrip(trip)
showShareSheet = true
}
private func toggleSaved() { private func toggleSaved() {
if isSaved { if isSaved {
unsaveTrip() unsaveTrip()