Files
Sportstime/SportsTime/Export/Sharing/ShareCardComponents.swift
Trey t d034ee8612 fix: multiple bug fixes and improvements
- Fix suggested trips showing wrong sports for cross-country trips
- Remove quick start sections from home variants (Classic, Spotify)
- Remove dead quickActions code from HomeView
- Fix pace capsule animation in TripCreationView
- Add text wrapping to achievement descriptions
- Improve poll parsing with better error handling
- Various sharing system improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 09:35:18 -06:00

402 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 body: 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.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)
}
}