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