From fe36f99bca3fdd5400659ccfdd9992f9b8b7cd6e Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 14 Jan 2026 08:54:37 -0600 Subject: [PATCH] feat(sharing): implement unified sharing system for social media Replace old ProgressCardGenerator with protocol-based sharing architecture supporting trips, achievements, and stadium progress. Features 8 color themes, Instagram Stories optimization (1080x1920), and reusable card components with map snapshots. Co-Authored-By: Claude Opus 4.5 --- .../Services/ProgressCardGenerator.swift | 606 ------------------ .../Sharing/AchievementCardGenerator.swift | 423 ++++++++++++ .../Sharing/ProgressCardGenerator.swift | 115 ++++ .../Export/Sharing/ShareCardComponents.swift | 412 ++++++++++++ SportsTime/Export/Sharing/ShareService.swift | 121 ++++ .../Export/Sharing/ShareableContent.swift | 157 +++++ .../Export/Sharing/TripCardGenerator.swift | 126 ++++ SportsTime/Export/Views/ShareButton.swift | 71 ++ .../Export/Views/SharePreviewView.swift | 318 +++++++++ .../ViewModels/ProgressViewModel.swift | 6 + .../Progress/Views/AchievementsListView.swift | 18 + .../Progress/Views/ProgressTabView.swift | 16 +- .../Features/Trip/Views/TripDetailView.swift | 22 +- 13 files changed, 1775 insertions(+), 636 deletions(-) delete mode 100644 SportsTime/Export/Services/ProgressCardGenerator.swift create mode 100644 SportsTime/Export/Sharing/AchievementCardGenerator.swift create mode 100644 SportsTime/Export/Sharing/ProgressCardGenerator.swift create mode 100644 SportsTime/Export/Sharing/ShareCardComponents.swift create mode 100644 SportsTime/Export/Sharing/ShareService.swift create mode 100644 SportsTime/Export/Sharing/ShareableContent.swift create mode 100644 SportsTime/Export/Sharing/TripCardGenerator.swift create mode 100644 SportsTime/Export/Views/ShareButton.swift create mode 100644 SportsTime/Export/Views/SharePreviewView.swift diff --git a/SportsTime/Export/Services/ProgressCardGenerator.swift b/SportsTime/Export/Services/ProgressCardGenerator.swift deleted file mode 100644 index c76d8e0..0000000 --- a/SportsTime/Export/Services/ProgressCardGenerator.swift +++ /dev/null @@ -1,606 +0,0 @@ -// -// ProgressCardGenerator.swift -// SportsTime -// -// Generates shareable progress cards for social media. -// Cards include progress ring, stats, optional username, and app branding. -// - -import SwiftUI -import UIKit -import MapKit - -// MARK: - Progress Card Generator - -@MainActor -final class ProgressCardGenerator { - - // Card dimensions (Instagram story size) - private static let cardSize = CGSize(width: 1080, height: 1920) - private static let mapSnapshotSize = CGSize(width: 1000, height: 500) - - // MARK: - Generate Card - - /// Generate a shareable progress card image with default options - /// - Parameter progress: The league progress data - /// - Returns: The generated UIImage - func generateCard(progress: LeagueProgress) async throws -> UIImage { - try await generateCard(progress: progress, options: ProgressCardOptions()) - } - - /// Generate a shareable progress card image - /// - Parameters: - /// - progress: The league progress data - /// - options: Card generation options - /// - Returns: The generated UIImage - func generateCard( - progress: LeagueProgress, - options: ProgressCardOptions - ) async throws -> UIImage { - // Generate map snapshot if needed - var mapSnapshot: UIImage? - if options.includeMapSnapshot { - mapSnapshot = await generateMapSnapshot( - visited: progress.stadiumsVisited, - remaining: progress.stadiumsRemaining - ) - } - - // Render SwiftUI view to image - let cardView = ProgressCardView( - progress: progress, - options: options, - mapSnapshot: mapSnapshot - ) - - let renderer = ImageRenderer(content: cardView) - renderer.scale = 3.0 // High resolution - - guard let image = renderer.uiImage else { - throw CardGeneratorError.renderingFailed - } - - return image - } - - /// Generate a map snapshot showing visited/unvisited stadiums - /// - Parameters: - /// - visited: Stadiums that have been visited - /// - remaining: Stadiums not yet visited - /// - Returns: The map snapshot image - func generateMapSnapshot( - visited: [Stadium], - remaining: [Stadium] - ) async -> UIImage? { - let allStadiums = visited + remaining - guard !allStadiums.isEmpty else { return nil } - - // Calculate region to show all stadiums - let coordinates = allStadiums.map { - CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) - } - - let minLat = coordinates.map(\.latitude).min() ?? 0 - let maxLat = coordinates.map(\.latitude).max() ?? 0 - let minLon = coordinates.map(\.longitude).min() ?? 0 - let maxLon = coordinates.map(\.longitude).max() ?? 0 - - let center = CLLocationCoordinate2D( - latitude: (minLat + maxLat) / 2, - longitude: (minLon + maxLon) / 2 - ) - - let span = MKCoordinateSpan( - latitudeDelta: (maxLat - minLat) * 1.3, - longitudeDelta: (maxLon - minLon) * 1.3 - ) - - let region = MKCoordinateRegion(center: center, span: span) - - // Create snapshot options - let options = MKMapSnapshotter.Options() - options.region = region - options.size = Self.mapSnapshotSize - options.mapType = .mutedStandard - - let snapshotter = MKMapSnapshotter(options: options) - - do { - let snapshot = try await snapshotter.start() - - // Draw annotations on snapshot - let image = UIGraphicsImageRenderer(size: Self.mapSnapshotSize).image { context in - snapshot.image.draw(at: .zero) - - // Draw stadium markers - for stadium in remaining { - let point = snapshot.point(for: CLLocationCoordinate2D( - latitude: stadium.latitude, - longitude: stadium.longitude - )) - drawMarker(at: point, color: .gray, context: context.cgContext) - } - - for stadium in visited { - let point = snapshot.point(for: CLLocationCoordinate2D( - latitude: stadium.latitude, - longitude: stadium.longitude - )) - drawMarker(at: point, color: UIColor(Theme.warmOrange), context: context.cgContext) - } - } - - return image - } catch { - return nil - } - } - - private func drawMarker(at point: CGPoint, color: UIColor, context: CGContext) { - let markerSize: CGFloat = 16 - - context.setFillColor(color.cgColor) - context.fillEllipse(in: CGRect( - x: point.x - markerSize / 2, - y: point.y - markerSize / 2, - width: markerSize, - height: markerSize - )) - - // White border - context.setStrokeColor(UIColor.white.cgColor) - context.setLineWidth(2) - context.strokeEllipse(in: CGRect( - x: point.x - markerSize / 2, - y: point.y - markerSize / 2, - width: markerSize, - height: markerSize - )) - } -} - -// MARK: - Card Generator Errors - -enum CardGeneratorError: Error, LocalizedError { - case renderingFailed - case mapSnapshotFailed - - var errorDescription: String? { - switch self { - case .renderingFailed: - return "Failed to render progress card" - case .mapSnapshotFailed: - return "Failed to generate map snapshot" - } - } -} - -// MARK: - Progress Card View - -struct ProgressCardView: View { - let progress: LeagueProgress - let options: ProgressCardOptions - let mapSnapshot: UIImage? - - var body: some View { - ZStack { - // Background gradient - LinearGradient( - colors: options.cardStyle == .dark - ? [Color(hex: "1A1A2E"), Color(hex: "16213E")] - : [Color.white, Color(hex: "F5F5F5")], - startPoint: .top, - endPoint: .bottom - ) - - VStack(spacing: 40) { - // App logo and title - headerSection - - Spacer() - - // Progress ring - progressRingSection - - // Stats row - if options.includeStats { - statsSection - } - - // Map snapshot - if options.includeMapSnapshot, let snapshot = mapSnapshot { - mapSection(image: snapshot) - } - - Spacer() - - // Username if included - if options.includeUsername, let username = options.username, !username.isEmpty { - usernameSection(username) - } - - // App branding footer - footerSection - } - .padding(60) - } - .frame(width: 1080, height: 1920) - } - - // MARK: - Header - - private var headerSection: some View { - VStack(spacing: 16) { - // Sport icon - ZStack { - Circle() - .fill(progress.sport.themeColor.opacity(0.2)) - .frame(width: 80, height: 80) - - Image(systemName: progress.sport.iconName) - .font(.system(size: 40)) - .foregroundStyle(progress.sport.themeColor) - } - - Text("\(progress.sport.displayName) Stadium Quest") - .font(.system(size: 48, weight: .bold, design: .rounded)) - .foregroundStyle(options.cardStyle.textColor) - } - } - - // MARK: - Progress Ring - - private var progressRingSection: some View { - ZStack { - // Background ring - Circle() - .stroke(Theme.warmOrange.opacity(0.2), lineWidth: 24) - .frame(width: 320, height: 320) - - // Progress ring - Circle() - .trim(from: 0, to: progress.completionPercentage / 100) - .stroke( - Theme.warmOrange, - style: StrokeStyle(lineWidth: 24, lineCap: .round) - ) - .frame(width: 320, height: 320) - .rotationEffect(.degrees(-90)) - - // Center content - VStack(spacing: 8) { - Text("\(progress.visitedStadiums)") - .font(.system(size: 96, weight: .bold, design: .rounded)) - .foregroundStyle(options.cardStyle.textColor) - - Text("of \(progress.totalStadiums)") - .font(.system(size: 32, weight: .medium)) - .foregroundStyle(options.cardStyle.secondaryTextColor) - - Text("Stadiums Visited") - .font(.system(size: 24)) - .foregroundStyle(options.cardStyle.secondaryTextColor) - } - } - } - - // MARK: - Stats - - private var statsSection: some View { - HStack(spacing: 60) { - statItem(value: "\(progress.visitedStadiums)", label: "Visited") - statItem(value: "\(progress.totalStadiums - progress.visitedStadiums)", label: "Remaining") - statItem(value: String(format: "%.0f%%", progress.completionPercentage), label: "Complete") - } - .padding(.vertical, 30) - .padding(.horizontal, 40) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(options.cardStyle == .dark - ? Color.white.opacity(0.05) - : Color.black.opacity(0.05)) - ) - } - - private func statItem(value: String, label: String) -> some View { - VStack(spacing: 8) { - Text(value) - .font(.system(size: 36, weight: .bold, design: .rounded)) - .foregroundStyle(Theme.warmOrange) - - Text(label) - .font(.system(size: 20)) - .foregroundStyle(options.cardStyle.secondaryTextColor) - } - } - - // MARK: - Map - - private func mapSection(image: UIImage) -> some View { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 960) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .overlay { - RoundedRectangle(cornerRadius: 20) - .stroke(Theme.warmOrange.opacity(0.3), lineWidth: 2) - } - } - - // MARK: - Username - - private func usernameSection(_ username: String) -> some View { - HStack(spacing: 12) { - Image(systemName: "person.circle.fill") - .font(.system(size: 24)) - Text(username) - .font(.system(size: 28, weight: .medium)) - } - .foregroundStyle(options.cardStyle.secondaryTextColor) - } - - // MARK: - Footer - - private var footerSection: some View { - VStack(spacing: 12) { - HStack(spacing: 8) { - Image(systemName: "sportscourt.fill") - .font(.system(size: 20)) - Text("SportsTime") - .font(.system(size: 24, weight: .semibold)) - } - .foregroundStyle(Theme.warmOrange) - - Text("Track your stadium adventures") - .font(.system(size: 18)) - .foregroundStyle(options.cardStyle.secondaryTextColor) - } - } -} - -// MARK: - Progress Share View - -struct ProgressShareView: View { - let progress: LeagueProgress - - @Environment(\.colorScheme) private var colorScheme - @Environment(\.dismiss) private var dismiss - - @State private var generatedImage: UIImage? - @State private var isGenerating = false - @State private var showShareSheet = false - @State private var error: String? - - @State private var includeUsername = true - @State private var username = "" - @State private var includeMap = true - @State private var cardStyle: ProgressCardOptions.CardStyle = .dark - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: Theme.Spacing.lg) { - // Preview card - previewCard - .padding(.horizontal) - - // Options - optionsSection - - // Generate button - generateButton - .padding(.horizontal) - } - .padding(.vertical) - } - .navigationTitle("Share Progress") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - } - .sheet(isPresented: $showShareSheet) { - if let image = generatedImage { - ShareSheet(items: [image]) - } - } - .alert("Error", isPresented: .constant(error != nil)) { - Button("OK") { error = nil } - } message: { - Text(error ?? "") - } - } - } - - private var previewCard: some View { - VStack(spacing: Theme.Spacing.md) { - Text("Preview") - .font(.subheadline) - .foregroundStyle(Theme.textMuted(colorScheme)) - - // Mini preview - ZStack { - RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) - .fill(cardStyle == .dark - ? Color(hex: "1A1A2E") - : Color.white) - .aspectRatio(9/16, contentMode: .fit) - .frame(maxHeight: 300) - - VStack(spacing: 12) { - // Sport badge - HStack(spacing: 4) { - Image(systemName: progress.sport.iconName) - Text(progress.sport.displayName) - } - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(progress.sport.themeColor) - - // Progress ring - ZStack { - Circle() - .stroke(Theme.warmOrange.opacity(0.2), lineWidth: 4) - .frame(width: 60, height: 60) - - Circle() - .trim(from: 0, to: progress.completionPercentage / 100) - .stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 4, lineCap: .round)) - .frame(width: 60, height: 60) - .rotationEffect(.degrees(-90)) - - VStack(spacing: 0) { - Text("\(progress.visitedStadiums)") - .font(.system(size: 18, weight: .bold)) - Text("/\(progress.totalStadiums)") - .font(.system(size: 10)) - } - .foregroundStyle(cardStyle == .dark ? .white : .black) - } - - if includeMap { - RoundedRectangle(cornerRadius: 4) - .fill(Color.gray.opacity(0.2)) - .frame(height: 40) - .overlay { - Image(systemName: "map") - .foregroundStyle(Color.gray) - } - } - - if includeUsername && !username.isEmpty { - Text("@\(username)") - .font(.system(size: 10)) - .foregroundStyle(cardStyle == .dark ? Color.gray : Color.gray) - } - - // Branding - HStack(spacing: 4) { - Image(systemName: "sportscourt.fill") - Text("SportsTime") - } - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(Theme.warmOrange) - } - .padding() - } - } - } - - private var optionsSection: some View { - VStack(spacing: Theme.Spacing.md) { - // Style selector - VStack(alignment: .leading, spacing: Theme.Spacing.xs) { - Text("Style") - .font(.subheadline) - .foregroundStyle(Theme.textMuted(colorScheme)) - - HStack(spacing: Theme.Spacing.sm) { - styleButton(style: .dark, label: "Dark") - styleButton(style: .light, label: "Light") - } - } - .padding(.horizontal) - - // Username toggle - Toggle(isOn: $includeUsername) { - Text("Include Username") - .font(.body) - } - .padding(.horizontal) - - if includeUsername { - TextField("Username", text: $username) - .textFieldStyle(.roundedBorder) - .padding(.horizontal) - } - - // Map toggle - Toggle(isOn: $includeMap) { - Text("Include Map") - .font(.body) - } - .padding(.horizontal) - } - .padding(.vertical) - .background(Theme.cardBackground(colorScheme)) - } - - private func styleButton(style: ProgressCardOptions.CardStyle, label: String) -> some View { - Button { - withAnimation { cardStyle = style } - } label: { - Text(label) - .font(.subheadline) - .foregroundStyle(cardStyle == style ? .white : Theme.textPrimary(colorScheme)) - .padding(.horizontal, Theme.Spacing.md) - .padding(.vertical, Theme.Spacing.sm) - .background(cardStyle == style ? Theme.warmOrange : Theme.cardBackgroundElevated(colorScheme)) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - } - - private var generateButton: some View { - Button { - generateCard() - } label: { - HStack { - if isGenerating { - LoadingSpinner(size: .small) - .colorScheme(.dark) - } else { - Image(systemName: "square.and.arrow.up") - } - Text(isGenerating ? "Generating..." : "Generate & Share") - } - .frame(maxWidth: .infinity) - .padding(Theme.Spacing.md) - .background(Theme.warmOrange) - .foregroundStyle(.white) - .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) - } - .disabled(isGenerating) - } - - private func generateCard() { - isGenerating = true - - Task { - let options = ProgressCardOptions( - includeUsername: includeUsername, - username: username, - includeMapSnapshot: includeMap, - includeStats: true, - cardStyle: cardStyle - ) - - let generator = ProgressCardGenerator() - - do { - generatedImage = try await generator.generateCard( - progress: progress, - options: options - ) - showShareSheet = true - } catch { - self.error = error.localizedDescription - } - - isGenerating = false - } - } -} - -// MARK: - Preview - -#Preview { - ProgressShareView(progress: LeagueProgress( - sport: .mlb, - totalStadiums: 30, - visitedStadiums: 12, - stadiumsVisited: [], - stadiumsRemaining: [] - )) -} diff --git a/SportsTime/Export/Sharing/AchievementCardGenerator.swift b/SportsTime/Export/Sharing/AchievementCardGenerator.swift new file mode 100644 index 0000000..c7ae6b9 --- /dev/null +++ b/SportsTime/Export/Sharing/AchievementCardGenerator.swift @@ -0,0 +1,423 @@ +// +// AchievementCardGenerator.swift +// SportsTime +// +// Generates shareable achievement cards: spotlight, collection, milestone, context. +// + +import SwiftUI +import UIKit + +// MARK: - Achievement Spotlight Content + +struct AchievementSpotlightContent: ShareableContent { + let achievement: AchievementProgress + + var cardType: ShareCardType { .achievementSpotlight } + + @MainActor + func render(theme: ShareTheme) async throws -> UIImage { + let cardView = AchievementSpotlightView( + achievement: achievement, + theme: theme + ) + + let renderer = ImageRenderer(content: cardView) + renderer.scale = 3.0 + + guard let image = renderer.uiImage else { + throw ShareError.renderingFailed + } + + return image + } +} + +// MARK: - Achievement Collection Content + +struct AchievementCollectionContent: ShareableContent { + let achievements: [AchievementProgress] + let year: Int + + var cardType: ShareCardType { .achievementCollection } + + @MainActor + func render(theme: ShareTheme) async throws -> UIImage { + let cardView = AchievementCollectionView( + achievements: achievements, + year: year, + theme: theme + ) + + let renderer = ImageRenderer(content: cardView) + renderer.scale = 3.0 + + guard let image = renderer.uiImage else { + throw ShareError.renderingFailed + } + + return image + } +} + +// MARK: - Achievement Milestone Content + +struct AchievementMilestoneContent: ShareableContent { + let achievement: AchievementProgress + + var cardType: ShareCardType { .achievementMilestone } + + @MainActor + func render(theme: ShareTheme) async throws -> UIImage { + let cardView = AchievementMilestoneView( + achievement: achievement, + theme: theme + ) + + let renderer = ImageRenderer(content: cardView) + renderer.scale = 3.0 + + guard let image = renderer.uiImage else { + throw ShareError.renderingFailed + } + + return image + } +} + +// MARK: - Achievement Context Content + +struct AchievementContextContent: ShareableContent { + let achievement: AchievementProgress + let tripName: String? + let mapSnapshot: UIImage? + + var cardType: ShareCardType { .achievementContext } + + @MainActor + func render(theme: ShareTheme) async throws -> UIImage { + let cardView = AchievementContextView( + achievement: achievement, + tripName: tripName, + mapSnapshot: mapSnapshot, + theme: theme + ) + + let renderer = ImageRenderer(content: cardView) + renderer.scale = 3.0 + + guard let image = renderer.uiImage else { + throw ShareError.renderingFailed + } + + return image + } +} + +// MARK: - Spotlight View + +private struct AchievementSpotlightView: View { + let achievement: AchievementProgress + let theme: ShareTheme + + var body: some View { + ZStack { + ShareCardBackground(theme: theme) + + VStack(spacing: 50) { + Spacer() + + // Badge + AchievementBadge( + definition: achievement.definition, + size: 400 + ) + + // Name + Text(achievement.definition.name) + .font(.system(size: 56, weight: .bold, design: .rounded)) + .foregroundStyle(theme.textColor) + + // Description + Text(achievement.definition.description) + .font(.system(size: 28)) + .foregroundStyle(theme.secondaryTextColor) + .multilineTextAlignment(.center) + .padding(.horizontal, 80) + + // Unlock date + if let earnedAt = achievement.earnedAt { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(theme.accentColor) + Text("Unlocked \(earnedAt.formatted(date: .abbreviated, time: .omitted))") + } + .font(.system(size: 24)) + .foregroundStyle(theme.secondaryTextColor) + } + + Spacer() + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame( + width: ShareCardDimensions.cardSize.width, + height: ShareCardDimensions.cardSize.height + ) + } +} + +// MARK: - Collection View + +private struct AchievementCollectionView: View { + let achievements: [AchievementProgress] + let year: Int + let theme: ShareTheme + + private let columns = [ + GridItem(.flexible(), spacing: 30), + GridItem(.flexible(), spacing: 30), + GridItem(.flexible(), spacing: 30) + ] + + var body: some View { + ZStack { + ShareCardBackground(theme: theme) + + VStack(spacing: 40) { + // Header + Text("My \(year) Achievements") + .font(.system(size: 48, weight: .bold, design: .rounded)) + .foregroundStyle(theme.textColor) + + Spacer() + + // Grid + LazyVGrid(columns: columns, spacing: 40) { + ForEach(achievements.prefix(12)) { achievement in + VStack(spacing: 12) { + AchievementBadge( + definition: achievement.definition, + size: 200 + ) + + Text(achievement.definition.name) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(theme.textColor) + .lineLimit(2) + .multilineTextAlignment(.center) + } + } + } + .padding(.horizontal, 40) + + Spacer() + + // Count + Text("\(achievements.count) achievements unlocked") + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(theme.secondaryTextColor) + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame( + width: ShareCardDimensions.cardSize.width, + height: ShareCardDimensions.cardSize.height + ) + } +} + +// MARK: - Milestone View + +private struct AchievementMilestoneView: View { + let achievement: AchievementProgress + let theme: ShareTheme + + private let goldColor = Color(hex: "FFD700") + + var body: some View { + ZStack { + ShareCardBackground(theme: theme) + + // Confetti burst pattern + ConfettiBurst() + .opacity(0.3) + + VStack(spacing: 40) { + Spacer() + + // Milestone label + Text("MILESTONE") + .font(.system(size: 24, weight: .black, design: .rounded)) + .tracking(4) + .foregroundStyle(goldColor) + + // Large badge + AchievementBadge( + definition: achievement.definition, + size: 500 + ) + .overlay { + Circle() + .stroke(goldColor, lineWidth: 4) + .frame(width: 520, height: 520) + } + + // Name + Text(achievement.definition.name) + .font(.system(size: 56, weight: .bold, design: .rounded)) + .foregroundStyle(theme.textColor) + + // Description + Text(achievement.definition.description) + .font(.system(size: 28)) + .foregroundStyle(theme.secondaryTextColor) + .multilineTextAlignment(.center) + .padding(.horizontal, 80) + + Spacer() + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame( + width: ShareCardDimensions.cardSize.width, + height: ShareCardDimensions.cardSize.height + ) + } +} + +// MARK: - Context View + +private struct AchievementContextView: View { + let achievement: AchievementProgress + let tripName: String? + let mapSnapshot: UIImage? + let theme: ShareTheme + + var body: some View { + ZStack { + ShareCardBackground(theme: theme) + + VStack(spacing: 40) { + // Header with badge and name + HStack(spacing: 24) { + AchievementBadge( + definition: achievement.definition, + size: 150 + ) + + VStack(alignment: .leading, spacing: 8) { + Text(achievement.definition.name) + .font(.system(size: 40, weight: .bold, design: .rounded)) + .foregroundStyle(theme.textColor) + + Text("Unlocked!") + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(theme.accentColor) + } + } + .padding(.top, 40) + + Spacer() + + // Context map or placeholder + if let snapshot = mapSnapshot { + Image(uiImage: snapshot) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 960, maxHeight: 700) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay { + RoundedRectangle(cornerRadius: 20) + .stroke(theme.accentColor.opacity(0.3), lineWidth: 2) + } + } + + // Trip name + if let tripName = tripName { + Text("Unlocked during my") + .font(.system(size: 24)) + .foregroundStyle(theme.secondaryTextColor) + + Text(tripName) + .font(.system(size: 32, weight: .semibold)) + .foregroundStyle(theme.textColor) + } + + Spacer() + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame( + width: ShareCardDimensions.cardSize.width, + height: ShareCardDimensions.cardSize.height + ) + } +} + +// MARK: - Achievement Badge + +private struct AchievementBadge: View { + let definition: AchievementDefinition + let size: CGFloat + + var body: some View { + ZStack { + Circle() + .fill(definition.iconColor.opacity(0.2)) + .frame(width: size, height: size) + + Circle() + .stroke(definition.iconColor, lineWidth: size * 0.02) + .frame(width: size * 0.9, height: size * 0.9) + + Image(systemName: definition.iconName) + .font(.system(size: size * 0.4)) + .foregroundStyle(definition.iconColor) + } + } +} + +// MARK: - Confetti Burst + +private struct ConfettiBurst: View { + var body: some View { + GeometryReader { geometry in + let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height * 0.4) + + ForEach(0..<24, id: \.self) { index in + let angle = Double(index) * (360.0 / 24.0) + let distance: CGFloat = CGFloat.random(in: 200...400) + let xOffset = cos(angle * .pi / 180) * distance + let yOffset = sin(angle * .pi / 180) * distance + + Circle() + .fill(confettiColor(for: index)) + .frame(width: CGFloat.random(in: 8...20)) + .position( + x: center.x + xOffset, + y: center.y + yOffset + ) + } + } + } + + private func confettiColor(for index: Int) -> Color { + let colors: [Color] = [ + Color(hex: "FFD700"), + Color(hex: "FF6B35"), + Color(hex: "00D4FF"), + Color(hex: "95D5B2"), + Color(hex: "FF85A1") + ] + return colors[index % colors.count] + } +} diff --git a/SportsTime/Export/Sharing/ProgressCardGenerator.swift b/SportsTime/Export/Sharing/ProgressCardGenerator.swift new file mode 100644 index 0000000..c981a95 --- /dev/null +++ b/SportsTime/Export/Sharing/ProgressCardGenerator.swift @@ -0,0 +1,115 @@ +// +// ProgressCardGenerator.swift +// SportsTime +// +// Generates shareable stadium progress cards. +// + +import SwiftUI +import UIKit + +// MARK: - Progress Share Content + +struct ProgressShareContent: ShareableContent { + let progress: LeagueProgress + let tripCount: Int + let username: String? + + var cardType: ShareCardType { .stadiumProgress } + + @MainActor + func render(theme: ShareTheme) async throws -> UIImage { + let mapGenerator = ShareMapSnapshotGenerator() + let mapSnapshot = await mapGenerator.generateProgressMap( + visited: progress.stadiumsVisited, + remaining: progress.stadiumsRemaining, + theme: theme + ) + + let cardView = ProgressCardView( + progress: progress, + tripCount: tripCount, + username: username, + theme: theme, + mapSnapshot: mapSnapshot + ) + + let renderer = ImageRenderer(content: cardView) + renderer.scale = 3.0 + + guard let image = renderer.uiImage else { + throw ShareError.renderingFailed + } + + return image + } +} + +// MARK: - Progress Card View + +private struct ProgressCardView: View { + let progress: LeagueProgress + let tripCount: Int + let username: String? + let theme: ShareTheme + let mapSnapshot: UIImage? + + var body: some View { + ZStack { + ShareCardBackground(theme: theme) + + VStack(spacing: 40) { + ShareCardHeader( + title: "\(progress.sport.displayName) Stadium Quest", + sport: progress.sport, + theme: theme + ) + + Spacer() + + // Progress ring + ShareProgressRing( + current: progress.visitedStadiums, + total: progress.totalStadiums, + theme: theme + ) + + Text("\(Int(progress.completionPercentage))% Complete") + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(theme.secondaryTextColor) + + // Stats row + ShareStatsRow( + stats: [ + (value: "\(progress.visitedStadiums)", label: "visited"), + (value: "\(progress.totalStadiums - progress.visitedStadiums)", label: "remain"), + (value: "\(tripCount)", label: "trips") + ], + theme: theme + ) + + // Map + if let snapshot = mapSnapshot { + Image(uiImage: snapshot) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 960) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay { + RoundedRectangle(cornerRadius: 20) + .stroke(theme.accentColor.opacity(0.3), lineWidth: 2) + } + } + + Spacer() + + ShareCardFooter(theme: theme, username: username) + } + .padding(ShareCardDimensions.padding) + } + .frame( + width: ShareCardDimensions.cardSize.width, + height: ShareCardDimensions.cardSize.height + ) + } +} diff --git a/SportsTime/Export/Sharing/ShareCardComponents.swift b/SportsTime/Export/Sharing/ShareCardComponents.swift new file mode 100644 index 0000000..22a8a3f --- /dev/null +++ b/SportsTime/Export/Sharing/ShareCardComponents.swift @@ -0,0 +1,412 @@ +// +// ShareCardComponents.swift +// SportsTime +// +// Reusable components for share cards: header, footer, stats row, map snapshot. +// + +import SwiftUI +import MapKit +import UIKit + +// MARK: - Card Background + +struct ShareCardBackground: View { + let theme: ShareTheme + + var body: some View { + LinearGradient( + colors: theme.gradientColors, + startPoint: .top, + endPoint: .bottom + ) + } +} + +// MARK: - Card Header + +struct ShareCardHeader: View { + let title: String + let sport: Sport? + let theme: ShareTheme + + var body: some View { + VStack(spacing: 16) { + if let sport = sport { + ZStack { + Circle() + .fill(theme.accentColor.opacity(0.2)) + .frame(width: 80, height: 80) + + Image(systemName: sport.iconName) + .font(.system(size: 40)) + .foregroundStyle(theme.accentColor) + } + } + + Text(title) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .foregroundStyle(theme.textColor) + .multilineTextAlignment(.center) + } + } +} + +// MARK: - Card Footer + +struct ShareCardFooter: View { + let theme: ShareTheme + var username: String? = nil + + var body: some View { + VStack(spacing: 12) { + if let username = username, !username.isEmpty { + HStack(spacing: 8) { + Image(systemName: "person.circle.fill") + .font(.system(size: 24)) + Text("@\(username)") + .font(.system(size: 28, weight: .medium)) + } + .foregroundStyle(theme.secondaryTextColor) + } + + HStack(spacing: 8) { + Image(systemName: "sportscourt.fill") + .font(.system(size: 20)) + Text("SportsTime") + .font(.system(size: 24, weight: .semibold)) + } + .foregroundStyle(theme.accentColor) + + Text("Plan your stadium adventure") + .font(.system(size: 18)) + .foregroundStyle(theme.secondaryTextColor) + } + } +} + +// MARK: - Stats Row + +struct ShareStatsRow: View { + let stats: [(value: String, label: String)] + let theme: ShareTheme + + var body: some View { + HStack(spacing: 60) { + ForEach(Array(stats.enumerated()), id: \.offset) { _, stat in + VStack(spacing: 8) { + Text(stat.value) + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundStyle(theme.accentColor) + + Text(stat.label) + .font(.system(size: 20)) + .foregroundStyle(theme.secondaryTextColor) + } + } + } + .padding(.vertical, 30) + .padding(.horizontal, 40) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(theme.textColor.opacity(0.05)) + ) + } +} + +// MARK: - Progress Ring + +struct ShareProgressRing: View { + let current: Int + let total: Int + let theme: ShareTheme + var size: CGFloat = 320 + var lineWidth: CGFloat = 24 + + private var progress: Double { + guard total > 0 else { return 0 } + return Double(current) / Double(total) + } + + var body: some View { + ZStack { + // Background ring + Circle() + .stroke(theme.accentColor.opacity(0.2), lineWidth: lineWidth) + .frame(width: size, height: size) + + // Progress ring + Circle() + .trim(from: 0, to: progress) + .stroke( + theme.accentColor, + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) + .frame(width: size, height: size) + .rotationEffect(.degrees(-90)) + + // Center content + VStack(spacing: 8) { + Text("\(current)") + .font(.system(size: 96, weight: .bold, design: .rounded)) + .foregroundStyle(theme.textColor) + + Text("of \(total)") + .font(.system(size: 32, weight: .medium)) + .foregroundStyle(theme.secondaryTextColor) + } + } + } +} + +// MARK: - Map Snapshot Generator + +@MainActor +final class ShareMapSnapshotGenerator { + + /// Generate a progress map showing visited/remaining stadiums + func generateProgressMap( + visited: [Stadium], + remaining: [Stadium], + theme: ShareTheme + ) async -> UIImage? { + let allStadiums = visited + remaining + guard !allStadiums.isEmpty else { return nil } + + let region = calculateRegion(for: allStadiums) + let options = MKMapSnapshotter.Options() + options.region = region + options.size = ShareCardDimensions.mapSnapshotSize + options.mapType = theme.useDarkMap ? .mutedStandard : .standard + + let snapshotter = MKMapSnapshotter(options: options) + + do { + let snapshot = try await snapshotter.start() + return drawStadiumMarkers( + on: snapshot, + visited: visited, + remaining: remaining, + accentColor: UIColor(theme.accentColor) + ) + } catch { + return nil + } + } + + /// Generate a route map for trip cards + func generateRouteMap( + stops: [TripStop], + theme: ShareTheme + ) async -> UIImage? { + let stopsWithCoordinates = stops.filter { $0.coordinate != nil } + guard stopsWithCoordinates.count >= 2 else { return nil } + + let coordinates = stopsWithCoordinates.compactMap { $0.coordinate } + + let region = calculateRegion(for: coordinates) + let options = MKMapSnapshotter.Options() + options.region = region + options.size = ShareCardDimensions.routeMapSize + options.mapType = theme.useDarkMap ? .mutedStandard : .standard + + let snapshotter = MKMapSnapshotter(options: options) + + do { + let snapshot = try await snapshotter.start() + return drawRoute( + on: snapshot, + stops: stopsWithCoordinates, + accentColor: UIColor(theme.accentColor) + ) + } catch { + return nil + } + } + + // MARK: - Private Helpers + + private func calculateRegion(for stadiums: [Stadium]) -> MKCoordinateRegion { + let coordinates = stadiums.map { + CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) + } + return calculateRegion(for: coordinates) + } + + private func calculateRegion(for coordinates: [CLLocationCoordinate2D]) -> MKCoordinateRegion { + let minLat = coordinates.map(\.latitude).min() ?? 0 + let maxLat = coordinates.map(\.latitude).max() ?? 0 + let minLon = coordinates.map(\.longitude).min() ?? 0 + let maxLon = coordinates.map(\.longitude).max() ?? 0 + + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ) + + let span = MKCoordinateSpan( + latitudeDelta: max((maxLat - minLat) * 1.4, 1), + longitudeDelta: max((maxLon - minLon) * 1.4, 1) + ) + + return MKCoordinateRegion(center: center, span: span) + } + + private func drawStadiumMarkers( + on snapshot: MKMapSnapshotter.Snapshot, + visited: [Stadium], + remaining: [Stadium], + accentColor: UIColor + ) -> UIImage { + let size = ShareCardDimensions.mapSnapshotSize + return UIGraphicsImageRenderer(size: size).image { context in + snapshot.image.draw(at: .zero) + + // Draw remaining (gray) first + for stadium in remaining { + let point = snapshot.point(for: CLLocationCoordinate2D( + latitude: stadium.latitude, + longitude: stadium.longitude + )) + drawMarker(at: point, color: .gray, context: context.cgContext) + } + + // Draw visited (accent) on top + for stadium in visited { + let point = snapshot.point(for: CLLocationCoordinate2D( + latitude: stadium.latitude, + longitude: stadium.longitude + )) + drawMarker(at: point, color: accentColor, context: context.cgContext) + } + } + } + + private func drawRoute( + on snapshot: MKMapSnapshotter.Snapshot, + stops: [TripStop], + accentColor: UIColor + ) -> UIImage { + let size = ShareCardDimensions.routeMapSize + return UIGraphicsImageRenderer(size: size).image { context in + snapshot.image.draw(at: .zero) + + let cgContext = context.cgContext + + // Draw route line + cgContext.setStrokeColor(accentColor.cgColor) + cgContext.setLineWidth(4) + cgContext.setLineCap(.round) + cgContext.setLineJoin(.round) + + let points = stops.compactMap { stop -> CGPoint? in + guard let coord = stop.coordinate else { return nil } + return snapshot.point(for: coord) + } + + if let first = points.first { + cgContext.move(to: first) + for point in points.dropFirst() { + cgContext.addLine(to: point) + } + cgContext.strokePath() + } + + // Draw city markers + for (index, stop) in stops.enumerated() { + guard let coord = stop.coordinate else { continue } + let point = snapshot.point(for: coord) + drawCityMarker( + at: point, + label: String(stop.city.prefix(3)).uppercased(), + isFirst: index == 0, + isLast: index == stops.count - 1, + color: accentColor, + context: cgContext + ) + } + } + } + + private func drawMarker(at point: CGPoint, color: UIColor, context: CGContext) { + let markerSize: CGFloat = 16 + + context.setFillColor(color.cgColor) + context.fillEllipse(in: CGRect( + x: point.x - markerSize / 2, + y: point.y - markerSize / 2, + width: markerSize, + height: markerSize + )) + + context.setStrokeColor(UIColor.white.cgColor) + context.setLineWidth(2) + context.strokeEllipse(in: CGRect( + x: point.x - markerSize / 2, + y: point.y - markerSize / 2, + width: markerSize, + height: markerSize + )) + } + + private func drawCityMarker( + at point: CGPoint, + label: String, + isFirst: Bool, + isLast: Bool, + color: UIColor, + context: CGContext + ) { + let markerSize: CGFloat = isFirst || isLast ? 24 : 18 + + // Outer circle + context.setFillColor(color.cgColor) + context.fillEllipse(in: CGRect( + x: point.x - markerSize / 2, + y: point.y - markerSize / 2, + width: markerSize, + height: markerSize + )) + + // White border + context.setStrokeColor(UIColor.white.cgColor) + context.setLineWidth(3) + context.strokeEllipse(in: CGRect( + x: point.x - markerSize / 2, + y: point.y - markerSize / 2, + width: markerSize, + height: markerSize + )) + + // Label above marker + let labelRect = CGRect( + x: point.x - 30, + y: point.y - markerSize / 2 - 22, + width: 60, + height: 20 + ) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 12, weight: .bold), + .foregroundColor: UIColor.white, + .paragraphStyle: paragraphStyle + ] + + // Draw label background + let labelBgRect = CGRect( + x: point.x - 22, + y: point.y - markerSize / 2 - 24, + width: 44, + height: 18 + ) + context.setFillColor(color.withAlphaComponent(0.9).cgColor) + let path = UIBezierPath(roundedRect: labelBgRect, cornerRadius: 4) + context.addPath(path.cgPath) + context.fillPath() + + label.draw(in: labelRect, withAttributes: attributes) + } +} diff --git a/SportsTime/Export/Sharing/ShareService.swift b/SportsTime/Export/Sharing/ShareService.swift new file mode 100644 index 0000000..d9ed938 --- /dev/null +++ b/SportsTime/Export/Sharing/ShareService.swift @@ -0,0 +1,121 @@ +// +// ShareService.swift +// SportsTime +// +// Handles Instagram direct share and fallback to system share sheet. +// + +import SwiftUI +import UIKit + +@MainActor +final class ShareService { + + static let shared = ShareService() + + private init() {} + + // MARK: - Share to Instagram + + func shareToInstagram(image: UIImage) -> Bool { + guard let imageData = image.pngData() else { return false } + + // Check if Instagram is installed + guard let instagramURL = URL(string: "instagram-stories://share"), + UIApplication.shared.canOpenURL(instagramURL) else { + return false + } + + // Set up pasteboard with image + let pasteboardItems: [String: Any] = [ + "com.instagram.sharedSticker.backgroundImage": imageData + ] + + UIPasteboard.general.setItems( + [pasteboardItems], + options: [.expirationDate: Date().addingTimeInterval(300)] + ) + + // Open Instagram Stories + let urlString = "instagram-stories://share?source_application=com.sportstime.app" + if let url = URL(string: urlString) { + UIApplication.shared.open(url) + return true + } + + return false + } + + // MARK: - Copy to Clipboard + + func copyToClipboard(image: UIImage) { + UIPasteboard.general.image = image + } + + // MARK: - System Share Sheet + + func presentShareSheet(image: UIImage, from viewController: UIViewController) { + let activityVC = UIActivityViewController( + activityItems: [image], + applicationActivities: nil + ) + + // iPad support + if let popover = activityVC.popoverPresentationController { + popover.sourceView = viewController.view + popover.sourceRect = CGRect( + x: viewController.view.bounds.midX, + y: viewController.view.bounds.midY, + width: 0, + height: 0 + ) + } + + viewController.present(activityVC, animated: true) + } +} + +// MARK: - Theme Persistence + +enum ShareThemePreferences { + private static let tripKey = "shareTheme.trip" + private static let achievementKey = "shareTheme.achievement" + private static let progressKey = "shareTheme.progress" + + static var tripTheme: ShareTheme { + get { ShareTheme.theme(byId: UserDefaults.standard.string(forKey: tripKey) ?? "dark") } + set { UserDefaults.standard.set(newValue.id, forKey: tripKey) } + } + + static var achievementTheme: ShareTheme { + get { ShareTheme.theme(byId: UserDefaults.standard.string(forKey: achievementKey) ?? "dark") } + set { UserDefaults.standard.set(newValue.id, forKey: achievementKey) } + } + + static var progressTheme: ShareTheme { + get { ShareTheme.theme(byId: UserDefaults.standard.string(forKey: progressKey) ?? "dark") } + set { UserDefaults.standard.set(newValue.id, forKey: progressKey) } + } + + static func theme(for cardType: ShareCardType) -> ShareTheme { + switch cardType { + case .tripSummary: + return tripTheme + case .achievementSpotlight, .achievementCollection, .achievementMilestone, .achievementContext: + return achievementTheme + case .stadiumProgress: + return progressTheme + } + } + + static func setTheme(_ theme: ShareTheme, for cardType: ShareCardType) { + switch cardType { + case .tripSummary: + tripTheme = theme + case .achievementSpotlight, .achievementCollection, .achievementMilestone, .achievementContext: + achievementTheme = theme + case .stadiumProgress: + progressTheme = theme + } + } +} diff --git a/SportsTime/Export/Sharing/ShareableContent.swift b/SportsTime/Export/Sharing/ShareableContent.swift new file mode 100644 index 0000000..3c3f98e --- /dev/null +++ b/SportsTime/Export/Sharing/ShareableContent.swift @@ -0,0 +1,157 @@ +// +// ShareableContent.swift +// SportsTime +// +// Protocol for shareable content and theme definitions. +// + +import SwiftUI +import UIKit + +// MARK: - Shareable Content Protocol + +protocol ShareableContent { + var cardType: ShareCardType { get } + func render(theme: ShareTheme) async throws -> UIImage +} + +// MARK: - Card Types + +enum ShareCardType: String, CaseIterable { + case tripSummary + case achievementSpotlight + case achievementCollection + case achievementMilestone + case achievementContext + case stadiumProgress +} + +// MARK: - Share Theme + +struct ShareTheme: Identifiable, Hashable { + let id: String + let name: String + let gradientColors: [Color] + let accentColor: Color + let textColor: Color + let secondaryTextColor: Color + let useDarkMap: Bool + + // MARK: - Preset Themes + + static let dark = ShareTheme( + id: "dark", + name: "Dark", + gradientColors: [Color(hex: "1A1A2E"), Color(hex: "16213E")], + accentColor: Color(hex: "FF6B35"), + textColor: .white, + secondaryTextColor: Color(hex: "B8B8D1"), + useDarkMap: true + ) + + static let light = ShareTheme( + id: "light", + name: "Light", + gradientColors: [.white, Color(hex: "F5F5F5")], + accentColor: Color(hex: "FF6B35"), + textColor: Color(hex: "1A1A2E"), + secondaryTextColor: Color(hex: "666666"), + useDarkMap: false + ) + + static let midnight = ShareTheme( + id: "midnight", + name: "Midnight", + gradientColors: [Color(hex: "0D1B2A"), Color(hex: "1B263B")], + accentColor: Color(hex: "00D4FF"), + textColor: .white, + secondaryTextColor: Color(hex: "A0AEC0"), + useDarkMap: true + ) + + static let forest = ShareTheme( + id: "forest", + name: "Forest", + gradientColors: [Color(hex: "1B4332"), Color(hex: "2D6A4F")], + accentColor: Color(hex: "95D5B2"), + textColor: .white, + secondaryTextColor: Color(hex: "B7E4C7"), + useDarkMap: false + ) + + static let sunset = ShareTheme( + id: "sunset", + name: "Sunset", + gradientColors: [Color(hex: "FF6B35"), Color(hex: "F7931E")], + accentColor: .white, + textColor: .white, + secondaryTextColor: Color(hex: "FFE5D9"), + useDarkMap: false + ) + + static let berry = ShareTheme( + id: "berry", + name: "Berry", + gradientColors: [Color(hex: "4A0E4E"), Color(hex: "81267E")], + accentColor: Color(hex: "FF85A1"), + textColor: .white, + secondaryTextColor: Color(hex: "E0B0FF"), + useDarkMap: true + ) + + static let ocean = ShareTheme( + id: "ocean", + name: "Ocean", + gradientColors: [Color(hex: "023E8A"), Color(hex: "0077B6")], + accentColor: Color(hex: "90E0EF"), + textColor: .white, + secondaryTextColor: Color(hex: "CAF0F8"), + useDarkMap: true + ) + + static let slate = ShareTheme( + id: "slate", + name: "Slate", + gradientColors: [Color(hex: "2B2D42"), Color(hex: "3D405B")], + accentColor: Color(hex: "F4A261"), + textColor: Color(hex: "EDF2F4"), + secondaryTextColor: Color(hex: "8D99AE"), + useDarkMap: true + ) + + static let all: [ShareTheme] = [.dark, .light, .midnight, .forest, .sunset, .berry, .ocean, .slate] + + static func theme(byId id: String) -> ShareTheme { + all.first { $0.id == id } ?? .dark + } +} + +// MARK: - Share Errors + +enum ShareError: Error, LocalizedError { + case renderingFailed + case mapSnapshotFailed + case instagramNotInstalled + + var errorDescription: String? { + switch self { + case .renderingFailed: + return "Failed to render share card" + case .mapSnapshotFailed: + return "Failed to generate map snapshot" + case .instagramNotInstalled: + return "Instagram is not installed" + } + } +} + +// MARK: - Card Dimensions + +enum ShareCardDimensions { + static let cardSize = CGSize(width: 1080, height: 1920) + static let mapSnapshotSize = CGSize(width: 1000, height: 500) + static let routeMapSize = CGSize(width: 1000, height: 600) + static let padding: CGFloat = 60 + static let headerHeight: CGFloat = 120 + static let footerHeight: CGFloat = 100 +} diff --git a/SportsTime/Export/Sharing/TripCardGenerator.swift b/SportsTime/Export/Sharing/TripCardGenerator.swift new file mode 100644 index 0000000..7418465 --- /dev/null +++ b/SportsTime/Export/Sharing/TripCardGenerator.swift @@ -0,0 +1,126 @@ +// +// TripCardGenerator.swift +// SportsTime +// +// Generates shareable trip summary cards with route map. +// + +import SwiftUI +import UIKit + +// MARK: - Trip Share Content + +struct TripShareContent: ShareableContent { + let trip: Trip + + var cardType: ShareCardType { .tripSummary } + + @MainActor + func render(theme: ShareTheme) async throws -> UIImage { + let mapGenerator = ShareMapSnapshotGenerator() + let mapSnapshot = await mapGenerator.generateRouteMap( + stops: trip.stops, + theme: theme + ) + + let cardView = TripCardView( + trip: trip, + theme: theme, + mapSnapshot: mapSnapshot + ) + + let renderer = ImageRenderer(content: cardView) + renderer.scale = 3.0 + + guard let image = renderer.uiImage else { + throw ShareError.renderingFailed + } + + return image + } +} + +// MARK: - Trip Card View + +private struct TripCardView: View { + let trip: Trip + let theme: ShareTheme + let mapSnapshot: UIImage? + + private var sportTitle: String { + if trip.uniqueSports.count == 1, let sport = trip.uniqueSports.first { + return "My \(sport.displayName) Road Trip" + } + return "My Sports Road Trip" + } + + private var primarySport: Sport? { + trip.uniqueSports.first + } + + var body: some View { + ZStack { + ShareCardBackground(theme: theme) + + VStack(spacing: 40) { + ShareCardHeader( + title: sportTitle, + sport: primarySport, + theme: theme + ) + + // Map + if let snapshot = mapSnapshot { + Image(uiImage: snapshot) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 960, maxHeight: 600) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay { + RoundedRectangle(cornerRadius: 20) + .stroke(theme.accentColor.opacity(0.3), lineWidth: 2) + } + } + + // Date range + Text(trip.formattedDateRange) + .font(.system(size: 32, weight: .medium)) + .foregroundStyle(theme.textColor) + + // Stats row + ShareStatsRow( + stats: [ + (value: String(format: "%.0f", trip.totalDistanceMiles), label: "miles"), + (value: "\(trip.totalGames)", label: "games"), + (value: "\(trip.cities.count)", label: "cities") + ], + theme: theme + ) + + // City trail + cityTrail + + Spacer() + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame( + width: ShareCardDimensions.cardSize.width, + height: ShareCardDimensions.cardSize.height + ) + } + + private var cityTrail: some View { + let cities = trip.cities + let displayText = cities.joined(separator: " → ") + + return Text(displayText) + .font(.system(size: 24, weight: .medium)) + .foregroundStyle(theme.secondaryTextColor) + .multilineTextAlignment(.center) + .lineLimit(3) + .padding(.horizontal, 40) + } +} diff --git a/SportsTime/Export/Views/ShareButton.swift b/SportsTime/Export/Views/ShareButton.swift new file mode 100644 index 0000000..dbdadfb --- /dev/null +++ b/SportsTime/Export/Views/ShareButton.swift @@ -0,0 +1,71 @@ +// +// 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 + } +} diff --git a/SportsTime/Export/Views/SharePreviewView.swift b/SportsTime/Export/Views/SharePreviewView.swift new file mode 100644 index 0000000..2545c5d --- /dev/null +++ b/SportsTime/Export/Views/SharePreviewView.swift @@ -0,0 +1,318 @@ +// +// 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 let progressContent = content as? ProgressShareContent { + // This is a workaround - ideally we'd have a more elegant solution + let modifiedContent = ProgressShareContent( + progress: progressContent.progress, + tripCount: progressContent.tripCount, + username: includeUsername ? (username.isEmpty ? nil : username) : nil + ) + generatedImage = try await modifiedContent.render(theme: selectedTheme) + } else { + generatedImage = try await content.render(theme: selectedTheme) + } + } catch { + self.error = error.localizedDescription + } + + isGenerating = false + } + + private func shareToInstagram() { + guard let image = generatedImage else { return } + + if !ShareService.shared.shareToInstagram(image: image) { + // Fallback to system share + showSystemShare() + } + } + + private func copyImage() { + guard let image = generatedImage else { return } + + ShareService.shared.copyToClipboard(image: image) + + withAnimation { + showCopiedToast = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + showCopiedToast = false + } + } + } + + private func showSystemShare() { + guard let image = generatedImage, + let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController else { return } + + ShareService.shared.presentShareSheet(image: image, from: rootVC) + } +} diff --git a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift index 9491651..7469d73 100644 --- a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift +++ b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift @@ -108,6 +108,12 @@ final class ProgressViewModel { leagueProgress.stadiumsRemaining } + /// Count of trips for the selected sport (stub - can be enhanced) + var tripCount: Int { + // TODO: Fetch saved trips count from SwiftData + 0 + } + /// Recent visits sorted by date var recentVisits: [VisitSummary] { visits diff --git a/SportsTime/Features/Progress/Views/AchievementsListView.swift b/SportsTime/Features/Progress/Views/AchievementsListView.swift index 6eb6005..eed88ec 100644 --- a/SportsTime/Features/Progress/Views/AchievementsListView.swift +++ b/SportsTime/Features/Progress/Views/AchievementsListView.swift @@ -36,6 +36,20 @@ struct AchievementsListView: View { } .themedBackground() .navigationTitle("Achievements") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if !earnedAchievements.isEmpty { + ShareButton( + content: AchievementCollectionContent( + achievements: earnedAchievements, + year: Calendar.current.component(.year, from: Date()) + ), + style: .icon + ) + .foregroundStyle(Theme.warmOrange) + } + } + } .task { await loadAchievements() } @@ -184,6 +198,10 @@ struct AchievementsListView: View { } } + private var earnedAchievements: [AchievementProgress] { + achievements.filter { $0.isEarned } + } + private var filteredAchievements: [AchievementProgress] { let filtered: [AchievementProgress] diff --git a/SportsTime/Features/Progress/Views/ProgressTabView.swift b/SportsTime/Features/Progress/Views/ProgressTabView.swift index 03477bb..d9e0ad6 100644 --- a/SportsTime/Features/Progress/Views/ProgressTabView.swift +++ b/SportsTime/Features/Progress/Views/ProgressTabView.swift @@ -15,7 +15,6 @@ struct ProgressTabView: View { @State private var viewModel = ProgressViewModel() @State private var showVisitSheet = false @State private var showPhotoImport = false - @State private var showShareSheet = false @State private var selectedStadium: Stadium? @State private var selectedVisitId: UUID? @@ -65,12 +64,12 @@ struct ProgressTabView: View { .themedBackground() .toolbar { ToolbarItem(placement: .topBarLeading) { - Button { - showShareSheet = true - } label: { - Image(systemName: "square.and.arrow.up") - .foregroundStyle(Theme.warmOrange) - } + ShareButton( + progress: viewModel.leagueProgress, + tripCount: viewModel.tripCount, + style: .icon + ) + .foregroundStyle(Theme.warmOrange) } ToolbarItem(placement: .primaryAction) { @@ -125,9 +124,6 @@ struct ProgressTabView: View { ) .presentationDetents([.medium]) } - .sheet(isPresented: $showShareSheet) { - ProgressShareView(progress: viewModel.leagueProgress) - } } // MARK: - League Selector diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 94fb50c..f01b232 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -18,9 +18,7 @@ struct TripDetailView: View { @State private var showProPaywall = false @State private var selectedDay: ItineraryDay? @State private var showExportSheet = false - @State private var showShareSheet = false @State private var exportURL: URL? - @State private var shareURL: URL? @State private var isExporting = false @State private var exportProgress: PDFAssetPrefetcher.PrefetchProgress? @State private var mapCameraPosition: MapCameraPosition = .automatic @@ -63,12 +61,8 @@ struct TripDetailView: View { .toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar) .toolbar { ToolbarItemGroup(placement: .primaryAction) { - Button { - shareTrip() - } label: { - Image(systemName: "square.and.arrow.up") - .foregroundStyle(Theme.warmOrange) - } + ShareButton(trip: trip, style: .icon) + .foregroundStyle(Theme.warmOrange) Button { if StoreManager.shared.isPro { @@ -94,13 +88,6 @@ struct TripDetailView: View { ShareSheet(items: [url]) } } - .sheet(isPresented: $showShareSheet) { - if let url = shareURL { - ShareSheet(items: [url]) - } else { - ShareSheet(items: [trip.name, trip.formattedDateRange]) - } - } .sheet(isPresented: $showProPaywall) { PaywallView() } @@ -523,11 +510,6 @@ struct TripDetailView: View { isExporting = false } - private func shareTrip() { - shareURL = exportService.shareTrip(trip) - showShareSheet = true - } - private func toggleSaved() { if isSaved { unsaveTrip()