# 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**