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: []
|
||||
))
|
||||
}
|
||||
423
SportsTime/Export/Sharing/AchievementCardGenerator.swift
Normal file
423
SportsTime/Export/Sharing/AchievementCardGenerator.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
115
SportsTime/Export/Sharing/ProgressCardGenerator.swift
Normal file
115
SportsTime/Export/Sharing/ProgressCardGenerator.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
412
SportsTime/Export/Sharing/ShareCardComponents.swift
Normal file
412
SportsTime/Export/Sharing/ShareCardComponents.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
121
SportsTime/Export/Sharing/ShareService.swift
Normal file
121
SportsTime/Export/Sharing/ShareService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
157
SportsTime/Export/Sharing/ShareableContent.swift
Normal file
157
SportsTime/Export/Sharing/ShareableContent.swift
Normal 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
|
||||
}
|
||||
126
SportsTime/Export/Sharing/TripCardGenerator.swift
Normal file
126
SportsTime/Export/Sharing/TripCardGenerator.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
71
SportsTime/Export/Views/ShareButton.swift
Normal file
71
SportsTime/Export/Views/ShareButton.swift
Normal 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
|
||||
}
|
||||
}
|
||||
318
SportsTime/Export/Views/SharePreviewView.swift
Normal file
318
SportsTime/Export/Views/SharePreviewView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user