Files
Sportstime/docs/plans/2026-01-13-sharing-overhaul-plan.md
Trey t 2b16420fb4 docs: add sharing overhaul implementation plan
13-task plan covering:
- Delete old ProgressCardGenerator
- Create ShareableContent protocol and 8 theme presets
- Create shared card components (header, footer, stats, maps)
- Create generators for progress, trip, and achievement cards
- Create ShareService for Instagram and system sharing
- Create SharePreviewView and ShareButton
- Integrate into ProgressTabView, TripDetailView, AchievementsListView

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:34:17 -06:00

60 KiB

Sharing System Overhaul Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace the existing sharing system with a unified, themeable sharing infrastructure supporting trip summaries, achievements, and stadium progress cards optimized for Instagram Stories.

Architecture: Protocol-based design where all shareable content types conform to ShareableContent, rendered via SwiftUI ImageRenderer at 3x scale. 8 color theme presets with per-content-type persistence. Contextual share buttons throughout the app trigger a unified SharePreviewView.

Tech Stack: SwiftUI, MapKit (MKMapSnapshotter), ImageRenderer, UIActivityViewController, Instagram URL scheme


Task 1: Delete Old Sharing Code

Files:

  • Delete: SportsTime/Export/Services/ProgressCardGenerator.swift

Step 1: Delete the old file

rm SportsTime/Export/Services/ProgressCardGenerator.swift

Step 2: Verify build still compiles (it won't yet - expected)

This will cause build errors in ProgressTabView.swift which references ProgressShareView. We'll fix these in Task 8.

Step 3: Commit

git add -A && git commit -m "chore: remove old ProgressCardGenerator (breaking - will fix)"

Task 2: Create ShareableContent Protocol and ShareTheme

Files:

  • Create: SportsTime/Export/Sharing/ShareableContent.swift

Step 1: Create the Sharing directory

mkdir -p SportsTime/Export/Sharing

Step 2: Write the protocol and theme definitions

//
//  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
}

Step 3: Add file to Xcode project

The file will be auto-detected by Xcode when placed in the correct location within the project directory.

Step 4: Commit

git add -A && git commit -m "feat(sharing): add ShareableContent protocol and ShareTheme definitions"

Task 3: Create Shared Card Components

Files:

  • Create: SportsTime/Export/Sharing/ShareCardComponents.swift

Step 1: Write shared UI components

//
//  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? {
        guard stops.count >= 2 else { return nil }

        let coordinates = stops.map {
            CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
        }

        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: stops,
                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.map {
                snapshot.point(for: CLLocationCoordinate2D(
                    latitude: $0.latitude,
                    longitude: $0.longitude
                ))
            }

            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() {
                let point = snapshot.point(for: CLLocationCoordinate2D(
                    latitude: stop.latitude,
                    longitude: stop.longitude
                ))
                drawCityMarker(
                    at: point,
                    label: 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)
    }
}

Step 2: Commit

git add -A && git commit -m "feat(sharing): add shared card components (header, footer, stats, map)"

Task 4: Create Progress Card Generator

Files:

  • Create: SportsTime/Export/Sharing/ProgressCardGenerator.swift

Step 1: Write the progress card generator

//
//  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
        )
    }
}

Step 2: Commit

git add -A && git commit -m "feat(sharing): add ProgressCardGenerator for stadium progress cards"

Task 5: Create Trip Card Generator

Files:

  • Create: SportsTime/Export/Sharing/TripCardGenerator.swift

Step 1: Write the trip card generator

//
//  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)
    }
}

Step 2: Commit

git add -A && git commit -m "feat(sharing): add TripCardGenerator for trip summary cards"

Task 6: Create Achievement Card Generator

Files:

  • Create: SportsTime/Export/Sharing/AchievementCardGenerator.swift

Step 1: Write the achievement card generator with all 4 card types

//
//  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]
    }
}

Step 2: Commit

git add -A && git commit -m "feat(sharing): add AchievementCardGenerator with 4 card types"

Task 7: Create Share Service

Files:

  • Create: SportsTime/Export/Sharing/ShareService.swift

Step 1: Write the share service

//
//  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
        }
    }
}

Step 2: Commit

git add -A && git commit -m "feat(sharing): add ShareService for Instagram and system sharing"

Task 8: Create Share Preview View and Share Button

Files:

  • Create: SportsTime/Export/Views/SharePreviewView.swift
  • Create: SportsTime/Export/Views/ShareButton.swift

Step 1: Create the Views directory if needed

mkdir -p SportsTime/Export/Views

Step 2: Write SharePreviewView

//
//  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 var 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)
    }
}

Step 3: Write ShareButton

//
//  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
    }
}

Step 4: Commit

git add -A && git commit -m "feat(sharing): add SharePreviewView and ShareButton components"

Task 9: Update ProgressTabView Integration

Files:

  • Modify: SportsTime/Features/Progress/Views/ProgressTabView.swift

Step 1: Replace the old share sheet reference

Find and replace the share button in the toolbar and the sheet presentation.

// In toolbar, replace:
ToolbarItem(placement: .topBarLeading) {
    Button {
        showShareSheet = true
    } label: {
        Image(systemName: "square.and.arrow.up")
            .foregroundStyle(Theme.warmOrange)
    }
}

// With:
ToolbarItem(placement: .topBarLeading) {
    ShareButton(
        progress: viewModel.leagueProgress,
        tripCount: viewModel.tripCount,
        style: .icon
    )
    .foregroundStyle(Theme.warmOrange)
}

Step 2: Remove the old sheet and state

Remove:

@State private var showShareSheet = false

// And remove this sheet:
.sheet(isPresented: $showShareSheet) {
    ProgressShareView(progress: viewModel.leagueProgress)
}

Step 3: Commit

git add -A && git commit -m "feat(sharing): integrate ShareButton into ProgressTabView"

Task 10: Update TripDetailView Integration

Files:

  • Modify: SportsTime/Features/Trip/Views/TripDetailView.swift

Step 1: Replace the old share functionality

Find the shareTrip() function and ShareSheet struct at the bottom of the file.

Remove the old ShareSheet struct definition (around line 911-918).

Remove the shareTrip() function and related state:

// Remove these state variables:
@State private var shareURL: URL?
@State private var showShareSheet = false

// Remove this function:
private func shareTrip() {
    shareURL = exportService.shareTrip(trip)
    showShareSheet = true
}

// Remove this sheet modifier:
.sheet(isPresented: $showShareSheet) {
    if let url = shareURL {
        ShareSheet(items: [url])
    }
}

Step 2: Add ShareButton to toolbar

Find the toolbar section and add:

ToolbarItem(placement: .topBarTrailing) {
    ShareButton(trip: trip, style: .icon)
}

Step 3: Commit

git add -A && git commit -m "feat(sharing): integrate ShareButton into TripDetailView"

Task 11: Add Share to AchievementsListView

Files:

  • Modify: SportsTime/Features/Progress/Views/AchievementsListView.swift

Step 1: Add toolbar share button for collection sharing

Add to the view's toolbar:

.toolbar {
    ToolbarItem(placement: .topBarTrailing) {
        if !earnedAchievements.isEmpty {
            ShareButton(
                content: AchievementCollectionContent(
                    achievements: earnedAchievements,
                    year: Calendar.current.component(.year, from: Date())
                ),
                style: .icon
            )
        }
    }
}

Where earnedAchievements is computed as:

private var earnedAchievements: [AchievementProgress] {
    achievements.filter { $0.isEarned }
}

Step 2: Add share to individual achievement badges

In the achievement grid item, add a context menu or tap action to share individual achievements.

Step 3: Commit

git add -A && git commit -m "feat(sharing): add share buttons to AchievementsListView"

Task 12: Build and Test

Step 1: Build the project

xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build

Step 2: Fix any compilation errors

Address any missing imports, type mismatches, or other issues.

Step 3: Run the app and test manually

  • Navigate to Progress tab → tap share button → verify preview and themes work
  • Navigate to a saved trip → tap share button → verify trip card generates
  • Navigate to Achievements → tap share button → verify collection card generates

Step 4: Commit any fixes

git add -A && git commit -m "fix(sharing): address build issues from integration"

Task 13: Final Cleanup

Step 1: Remove any unused imports from modified files

Step 2: Update ProgressViewModel if needed

Add tripCount property if not already present:

var tripCount: Int {
    // Count of completed trips for this sport
    // Implementation depends on your data model
    0
}

Step 3: Final commit

git add -A && git commit -m "chore(sharing): cleanup and finalize sharing overhaul"

Summary

Task Description Files
1 Delete old ProgressCardGenerator 1 deleted
2 Create ShareableContent protocol + themes 1 new
3 Create shared card components 1 new
4 Create ProgressCardGenerator 1 new
5 Create TripCardGenerator 1 new
6 Create AchievementCardGenerator 1 new
7 Create ShareService 1 new
8 Create SharePreviewView + ShareButton 2 new
9 Update ProgressTabView 1 modified
10 Update TripDetailView 1 modified
11 Update AchievementsListView 1 modified
12 Build and test -
13 Final cleanup -

Total: 8 new files, 1 deleted, 3+ modified