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 <noreply@anthropic.com>
413 lines
12 KiB
Swift
413 lines
12 KiB
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? {
|
|
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)
|
|
}
|
|
}
|