Files
Sportstime/SportsTime/Export/Sharing/ShareCardComponents.swift
Trey t 244ea5e107 feat: redesign all share cards, remove unused achievement types, fix sport selector
Redesign trip, progress, and achievement share cards with premium
sports-media aesthetic. Remove unused milestone/context achievement card
types (only used in debug exporter). Fix gold text unreadable in light
mode. Fix sport selector to only show stroke on selected sport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:55:53 -06:00

455 lines
15 KiB
Swift

//
// ShareCardComponents.swift
// SportsTime
//
// Shared building blocks for the new shareable card system.
//
import SwiftUI
import MapKit
import UIKit
// MARK: - Card Background
struct ShareCardBackground: View {
let theme: ShareTheme
var sports: Set<Sport>? = nil
var body: some View {
ShareCardSportBackground(sports: sports ?? [], theme: theme)
}
}
// MARK: - Card Header
struct ShareCardHeader: View {
let title: String
let sport: Sport?
let theme: ShareTheme
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .center, spacing: 18) {
if let sport = sport {
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(theme.accentColor.opacity(0.22))
.frame(width: 88, height: 88)
RoundedRectangle(cornerRadius: 20)
.stroke(theme.accentColor.opacity(0.65), lineWidth: 1.5)
.frame(width: 88, height: 88)
Image(systemName: sport.iconName)
.font(.system(size: 40, weight: .bold))
.foregroundStyle(theme.accentColor)
}
.shadow(color: .black.opacity(0.22), radius: 10, y: 6)
}
VStack(alignment: .leading, spacing: 7) {
Text("SPORTSTIME")
.font(.system(size: 15, weight: .bold))
.tracking(5)
.foregroundStyle(theme.secondaryTextColor)
Text(title)
.font(.system(size: 47, weight: .black, design: .default))
.foregroundStyle(theme.textColor)
.lineLimit(2)
.minimumScaleFactor(0.65)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
Capsule()
.fill(
LinearGradient(
colors: theme.highlightGradient,
startPoint: .leading,
endPoint: .trailing
)
)
.frame(height: 4)
}
.padding(.horizontal, 26)
.padding(.vertical, 22)
.background(
RoundedRectangle(cornerRadius: 30)
.fill(theme.surfaceColor)
.overlay(
RoundedRectangle(cornerRadius: 30)
.stroke(theme.borderColor, lineWidth: 1)
)
)
}
}
// MARK: - Card Footer
struct ShareCardFooter: View {
let theme: ShareTheme
var body: some View {
HStack(spacing: 14) {
Text("SPORTSTIME")
.font(.system(size: 16, weight: .black))
.tracking(3.5)
.foregroundStyle(theme.accentColor)
Capsule()
.fill(theme.borderColor)
.frame(width: 26, height: 2)
Text("build your next game-day route")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
Spacer(minLength: 0)
}
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(theme.surfaceColor)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(theme.borderColor, lineWidth: 1)
)
)
}
}
// MARK: - Stats Row
struct ShareStatsRow: View {
let stats: [(value: String, label: String)]
let theme: ShareTheme
var body: some View {
HStack(spacing: 14) {
ForEach(Array(stats.enumerated()), id: \.offset) { _, stat in
VStack(spacing: 7) {
Text(stat.value)
.font(.system(size: 44, weight: .black, design: .rounded))
.foregroundStyle(theme.accentColor)
.minimumScaleFactor(0.7)
.lineLimit(1)
Text(stat.label.uppercased())
.font(.system(size: 13, weight: .bold))
.tracking(2.2)
.foregroundStyle(theme.secondaryTextColor)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(theme.surfaceColor)
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(theme.borderColor, lineWidth: 1)
)
)
}
}
}
}
// MARK: - Progress Ring
struct ShareProgressRing: View {
let current: Int
let total: Int
let theme: ShareTheme
var size: CGFloat = 340
var lineWidth: CGFloat = 30
private let segmentCount = 72
private var progress: Double {
guard total > 0 else { return 0 }
return min(max(Double(current) / Double(total), 0), 1)
}
private var filledSegments: Int {
Int(round(progress * Double(segmentCount)))
}
var body: some View {
ZStack {
ForEach(0..<segmentCount, id: \.self) { index in
Capsule()
.fill(index < filledSegments ? theme.accentColor : theme.surfaceColor.opacity(0.90))
.frame(width: lineWidth * 0.62, height: lineWidth * 1.18)
.offset(y: -(size / 2 - lineWidth * 0.78))
.rotationEffect(.degrees(Double(index) * 360 / Double(segmentCount)))
}
ForEach([0.25, 0.50, 0.75], id: \.self) { mark in
Circle()
.fill(theme.textColor.opacity(0.32))
.frame(width: 8, height: 8)
.offset(y: -(size / 2 - lineWidth * 0.20))
.rotationEffect(.degrees(mark * 360))
}
Circle()
.stroke(theme.glowColor.opacity(0.45), lineWidth: 14)
.blur(radius: 12)
.frame(width: size - lineWidth * 1.4, height: size - lineWidth * 1.4)
VStack(spacing: 4) {
Text("\(current)")
.font(.system(size: 108, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor)
Text("of \(total)")
.font(.system(size: 27, weight: .light))
.foregroundStyle(theme.secondaryTextColor)
}
}
.frame(width: size, height: size)
}
}
// MARK: - Map Snapshot Generator
@MainActor
final class ShareMapSnapshotGenerator {
func generateProgressMap(
visited: [Stadium],
remaining: [Stadium],
theme: ShareTheme
) async -> UIImage? {
let allStadiums = visited + remaining
guard !allStadiums.isEmpty else { return nil }
let options = MKMapSnapshotter.Options()
options.region = calculateRegion(for: allStadiums)
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
}
}
func generateRouteMap(
stops: [TripStop],
theme: ShareTheme
) async -> UIImage? {
let validStops = stops.filter { $0.coordinate != nil }
guard validStops.count >= 2 else { return nil }
let options = MKMapSnapshotter.Options()
options.region = calculateRegion(for: validStops.compactMap { $0.coordinate })
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: validStops, accentColor: UIColor(theme.accentColor))
} catch {
return nil
}
}
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
return MKCoordinateRegion(
center: CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
),
span: MKCoordinateSpan(
latitudeDelta: max((maxLat - minLat) * 1.35, 1),
longitudeDelta: max((maxLon - minLon) * 1.35, 1)
)
)
}
private func drawStadiumMarkers(
on snapshot: MKMapSnapshotter.Snapshot,
visited: [Stadium],
remaining: [Stadium],
accentColor: UIColor
) -> UIImage {
UIGraphicsImageRenderer(size: ShareCardDimensions.mapSnapshotSize).image { context in
snapshot.image.draw(at: .zero)
for stadium in remaining {
drawStadiumDot(
at: snapshot.point(for: CLLocationCoordinate2D(latitude: stadium.latitude, longitude: stadium.longitude)),
color: UIColor.systemGray3,
visited: false,
context: context.cgContext
)
}
for stadium in visited {
drawStadiumDot(
at: snapshot.point(for: CLLocationCoordinate2D(latitude: stadium.latitude, longitude: stadium.longitude)),
color: accentColor,
visited: true,
context: context.cgContext
)
}
}
}
private func drawRoute(
on snapshot: MKMapSnapshotter.Snapshot,
stops: [TripStop],
accentColor: UIColor
) -> UIImage {
UIGraphicsImageRenderer(size: ShareCardDimensions.routeMapSize).image { context in
snapshot.image.draw(at: .zero)
let cg = context.cgContext
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 {
cg.setLineCap(.round)
cg.setLineJoin(.round)
cg.setStrokeColor(UIColor.black.withAlphaComponent(0.28).cgColor)
cg.setLineWidth(11)
cg.move(to: first)
for point in points.dropFirst() {
cg.addLine(to: point)
}
cg.strokePath()
cg.setStrokeColor(accentColor.cgColor)
cg.setLineWidth(6)
cg.move(to: first)
for point in points.dropFirst() {
cg.addLine(to: point)
}
cg.strokePath()
}
for (index, stop) in stops.enumerated() {
guard let coord = stop.coordinate else { continue }
let point = snapshot.point(for: coord)
let isFirst = index == 0
let isLast = index == stops.count - 1
drawCityLabel(
at: point,
label: isFirst ? "START" : isLast ? "FINISH" : String(stop.city.prefix(3)).uppercased(),
endpoint: isFirst || isLast,
color: accentColor,
context: cg
)
}
}
}
private func drawStadiumDot(
at point: CGPoint,
color: UIColor,
visited: Bool,
context: CGContext
) {
let size: CGFloat = 22
context.setFillColor(UIColor.black.withAlphaComponent(0.28).cgColor)
context.fillEllipse(in: CGRect(x: point.x - size / 2 - 3, y: point.y - size / 2 + 2, width: size + 6, height: size + 6))
context.setFillColor(UIColor.white.cgColor)
context.fillEllipse(in: CGRect(x: point.x - size / 2 - 2, y: point.y - size / 2 - 2, width: size + 4, height: size + 4))
context.setFillColor(color.cgColor)
context.fillEllipse(in: CGRect(x: point.x - size / 2, y: point.y - size / 2, width: size, height: size))
if visited {
context.setStrokeColor(UIColor.white.cgColor)
context.setLineWidth(2.6)
context.setLineCap(.round)
context.setLineJoin(.round)
context.move(to: CGPoint(x: point.x - 4.5, y: point.y + 0.5))
context.addLine(to: CGPoint(x: point.x - 0.8, y: point.y + 4.6))
context.addLine(to: CGPoint(x: point.x + 6.2, y: point.y - 3.2))
context.strokePath()
}
}
private func drawCityLabel(
at point: CGPoint,
label: String,
endpoint: Bool,
color: UIColor,
context: CGContext
) {
let dotSize: CGFloat = endpoint ? 22 : 17
context.setFillColor(UIColor.white.cgColor)
context.fillEllipse(in: CGRect(x: point.x - dotSize / 2 - 2, y: point.y - dotSize / 2 - 2, width: dotSize + 4, height: dotSize + 4))
context.setFillColor(color.cgColor)
context.fillEllipse(in: CGRect(x: point.x - dotSize / 2, y: point.y - dotSize / 2, width: dotSize, height: dotSize))
let font = UIFont.systemFont(ofSize: endpoint ? 12.5 : 11, weight: .heavy)
let attrs: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: UIColor.white
]
let textSize = (label as NSString).size(withAttributes: attrs)
let bgRect = CGRect(
x: point.x - textSize.width / 2 - 11,
y: point.y - dotSize / 2 - textSize.height - 12,
width: textSize.width + 22,
height: textSize.height + 8
)
let path = UIBezierPath(roundedRect: bgRect, cornerRadius: 9)
context.setFillColor(color.withAlphaComponent(0.94).cgColor)
context.addPath(path.cgPath)
context.fillPath()
(label as NSString).draw(
in: CGRect(
x: bgRect.origin.x + 11,
y: bgRect.origin.y + 4,
width: textSize.width,
height: textSize.height
),
withAttributes: attrs
)
}
}