From 2b16420fb4cde4db5f54afb3395613182385ce82 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 13 Jan 2026 22:34:17 -0600 Subject: [PATCH] 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 --- .../plans/2026-01-13-sharing-overhaul-plan.md | 2141 +++++++++++++++++ 1 file changed, 2141 insertions(+) create mode 100644 docs/plans/2026-01-13-sharing-overhaul-plan.md diff --git a/docs/plans/2026-01-13-sharing-overhaul-plan.md b/docs/plans/2026-01-13-sharing-overhaul-plan.md new file mode 100644 index 0000000..f69cfe7 --- /dev/null +++ b/docs/plans/2026-01-13-sharing-overhaul-plan.md @@ -0,0 +1,2141 @@ +# 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** + +```bash +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** + +```bash +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** + +```bash +mkdir -p SportsTime/Export/Sharing +``` + +**Step 2: Write the protocol and theme definitions** + +```swift +// +// 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** + +```bash +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** + +```swift +// +// 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** + +```bash +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** + +```swift +// +// 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** + +```bash +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** + +```swift +// +// 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** + +```bash +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** + +```swift +// +// 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** + +```bash +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** + +```swift +// +// 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** + +```bash +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** + +```bash +mkdir -p SportsTime/Export/Views +``` + +**Step 2: Write SharePreviewView** + +```swift +// +// SharePreviewView.swift +// SportsTime +// +// Unified preview and customization UI for all shareable content. +// + +import SwiftUI + +struct SharePreviewView: 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** + +```swift +// +// ShareButton.swift +// SportsTime +// +// Contextual share button component. +// + +import SwiftUI + +struct ShareButton: 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** + +```bash +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. + +```swift +// 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: +```swift +@State private var showShareSheet = false + +// And remove this sheet: +.sheet(isPresented: $showShareSheet) { + ProgressShareView(progress: viewModel.leagueProgress) +} +``` + +**Step 3: Commit** + +```bash +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: +```swift +// 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: +```swift +ToolbarItem(placement: .topBarTrailing) { + ShareButton(trip: trip, style: .icon) +} +``` + +**Step 3: Commit** + +```bash +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: +```swift +.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: +```swift +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** + +```bash +git add -A && git commit -m "feat(sharing): add share buttons to AchievementsListView" +``` + +--- + +## Task 12: Build and Test + +**Step 1: Build the project** + +```bash +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** + +```bash +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: +```swift +var tripCount: Int { + // Count of completed trips for this sport + // Implementation depends on your data model + 0 +} +``` + +**Step 3: Final commit** + +```bash +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**