13-task plan covering: - Delete old ProgressCardGenerator - Create ShareableContent protocol and 8 theme presets - Create shared card components (header, footer, stats, maps) - Create generators for progress, trip, and achievement cards - Create ShareService for Instagram and system sharing - Create SharePreviewView and ShareButton - Integrate into ProgressTabView, TripDetailView, AchievementsListView Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2142 lines
60 KiB
Markdown
2142 lines
60 KiB
Markdown
# 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<Content: ShareableContent>: View {
|
|
let content: Content
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
@State private var selectedTheme: ShareTheme
|
|
@State private var generatedImage: UIImage?
|
|
@State private var isGenerating = false
|
|
@State private var error: String?
|
|
@State private var showCopiedToast = false
|
|
|
|
// Progress-specific options
|
|
@State private var includeUsername = true
|
|
@State private var username = ""
|
|
|
|
init(content: Content) {
|
|
self.content = content
|
|
_selectedTheme = State(initialValue: ShareThemePreferences.theme(for: content.cardType))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: Theme.Spacing.lg) {
|
|
// Preview
|
|
previewSection
|
|
|
|
// Theme selector
|
|
themeSelector
|
|
|
|
// Username toggle (progress cards only)
|
|
if content.cardType == .stadiumProgress {
|
|
usernameSection
|
|
}
|
|
|
|
// Action buttons
|
|
actionButtons
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
}
|
|
.themedBackground()
|
|
.navigationTitle("Share")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
}
|
|
.alert("Error", isPresented: .constant(error != nil)) {
|
|
Button("OK") { error = nil }
|
|
} message: {
|
|
Text(error ?? "")
|
|
}
|
|
.overlay {
|
|
if showCopiedToast {
|
|
copiedToast
|
|
}
|
|
}
|
|
.task {
|
|
await generatePreview()
|
|
}
|
|
.onChange(of: selectedTheme) { _, _ in
|
|
Task { await generatePreview() }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview Section
|
|
|
|
private var previewSection: some View {
|
|
VStack(spacing: Theme.Spacing.sm) {
|
|
Text("Preview")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
|
|
if let image = generatedImage {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.aspectRatio(9/16, contentMode: .fit)
|
|
.frame(maxHeight: 400)
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
.shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5)
|
|
} else if isGenerating {
|
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
|
.fill(Theme.cardBackground(colorScheme))
|
|
.aspectRatio(9/16, contentMode: .fit)
|
|
.frame(maxHeight: 400)
|
|
.overlay {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Theme Selector
|
|
|
|
private var themeSelector: some View {
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
|
Text("Theme")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
ForEach(ShareTheme.all) { theme in
|
|
themeButton(theme)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func themeButton(_ theme: ShareTheme) -> some View {
|
|
Button {
|
|
withAnimation {
|
|
selectedTheme = theme
|
|
ShareThemePreferences.setTheme(theme, for: content.cardType)
|
|
}
|
|
} label: {
|
|
VStack(spacing: 4) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: theme.gradientColors,
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.frame(width: 50, height: 50)
|
|
|
|
Circle()
|
|
.fill(theme.accentColor)
|
|
.frame(width: 16, height: 16)
|
|
}
|
|
.overlay {
|
|
if selectedTheme.id == theme.id {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Theme.warmOrange, lineWidth: 3)
|
|
}
|
|
}
|
|
|
|
Text(theme.name)
|
|
.font(.caption2)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// MARK: - Username Section
|
|
|
|
private var usernameSection: some View {
|
|
VStack(spacing: Theme.Spacing.sm) {
|
|
Toggle(isOn: $includeUsername) {
|
|
Text("Include username")
|
|
}
|
|
.onChange(of: includeUsername) { _, _ in
|
|
Task { await generatePreview() }
|
|
}
|
|
|
|
if includeUsername {
|
|
TextField("@username", text: $username)
|
|
.textFieldStyle(.roundedBorder)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
.onChange(of: username) { _, _ in
|
|
Task { await generatePreview() }
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Theme.cardBackground(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
|
|
// MARK: - Action Buttons
|
|
|
|
private var actionButtons: some View {
|
|
VStack(spacing: Theme.Spacing.sm) {
|
|
// Primary: Share to Instagram
|
|
Button {
|
|
shareToInstagram()
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "camera.fill")
|
|
Text("Share to Instagram")
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.warmOrange)
|
|
.foregroundStyle(.white)
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
.disabled(generatedImage == nil)
|
|
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
// Copy Image
|
|
Button {
|
|
copyImage()
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "doc.on.doc")
|
|
Text("Copy")
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
.disabled(generatedImage == nil)
|
|
|
|
// More Options
|
|
Button {
|
|
showSystemShare()
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "ellipsis.circle")
|
|
Text("More")
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
}
|
|
.disabled(generatedImage == nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Copied Toast
|
|
|
|
private var copiedToast: some View {
|
|
VStack {
|
|
Spacer()
|
|
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
Text("Copied to clipboard")
|
|
}
|
|
.padding()
|
|
.background(.ultraThinMaterial)
|
|
.clipShape(Capsule())
|
|
.padding(.bottom, 100)
|
|
}
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func generatePreview() async {
|
|
isGenerating = true
|
|
|
|
do {
|
|
// For progress content, we may need to inject username
|
|
if var progressContent = content as? ProgressShareContent {
|
|
// This is a workaround - ideally we'd have a more elegant solution
|
|
let modifiedContent = ProgressShareContent(
|
|
progress: progressContent.progress,
|
|
tripCount: progressContent.tripCount,
|
|
username: includeUsername ? (username.isEmpty ? nil : username) : nil
|
|
)
|
|
generatedImage = try await modifiedContent.render(theme: selectedTheme)
|
|
} else {
|
|
generatedImage = try await content.render(theme: selectedTheme)
|
|
}
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
|
|
isGenerating = false
|
|
}
|
|
|
|
private func shareToInstagram() {
|
|
guard let image = generatedImage else { return }
|
|
|
|
if !ShareService.shared.shareToInstagram(image: image) {
|
|
// Fallback to system share
|
|
showSystemShare()
|
|
}
|
|
}
|
|
|
|
private func copyImage() {
|
|
guard let image = generatedImage else { return }
|
|
|
|
ShareService.shared.copyToClipboard(image: image)
|
|
|
|
withAnimation {
|
|
showCopiedToast = true
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
|
withAnimation {
|
|
showCopiedToast = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func showSystemShare() {
|
|
guard let image = generatedImage,
|
|
let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
|
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
|
|
|
ShareService.shared.presentShareSheet(image: image, from: rootVC)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Write ShareButton**
|
|
|
|
```swift
|
|
//
|
|
// ShareButton.swift
|
|
// SportsTime
|
|
//
|
|
// Contextual share button component.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ShareButton<Content: ShareableContent>: View {
|
|
let content: Content
|
|
var style: ShareButtonStyle = .icon
|
|
|
|
@State private var showPreview = false
|
|
|
|
var body: some View {
|
|
Button {
|
|
showPreview = true
|
|
} label: {
|
|
switch style {
|
|
case .icon:
|
|
Image(systemName: "square.and.arrow.up")
|
|
case .labeled:
|
|
Label("Share", systemImage: "square.and.arrow.up")
|
|
case .pill:
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
Text("Share")
|
|
}
|
|
.font(.subheadline.weight(.medium))
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Theme.warmOrange)
|
|
.foregroundStyle(.white)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
.sheet(isPresented: $showPreview) {
|
|
SharePreviewView(content: content)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ShareButtonStyle {
|
|
case icon
|
|
case labeled
|
|
case pill
|
|
}
|
|
|
|
// MARK: - Convenience Initializers
|
|
|
|
extension ShareButton where Content == TripShareContent {
|
|
init(trip: Trip, style: ShareButtonStyle = .icon) {
|
|
self.content = TripShareContent(trip: trip)
|
|
self.style = style
|
|
}
|
|
}
|
|
|
|
extension ShareButton where Content == ProgressShareContent {
|
|
init(progress: LeagueProgress, tripCount: Int = 0, username: String? = nil, style: ShareButtonStyle = .icon) {
|
|
self.content = ProgressShareContent(progress: progress, tripCount: tripCount, username: username)
|
|
self.style = style
|
|
}
|
|
}
|
|
|
|
extension ShareButton where Content == AchievementSpotlightContent {
|
|
init(achievement: AchievementProgress, style: ShareButtonStyle = .icon) {
|
|
self.content = AchievementSpotlightContent(achievement: achievement)
|
|
self.style = style
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```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**
|