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>
210 lines
7.1 KiB
Swift
210 lines
7.1 KiB
Swift
//
|
|
// TripCardGenerator.swift
|
|
// SportsTime
|
|
//
|
|
// Shareable trip cards — unified design language.
|
|
// Solid color bg, plain white text, no panels/borders, app icon footer.
|
|
//
|
|
|
|
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 renderer = ImageRenderer(content: TripCardView(trip: trip, theme: theme, mapSnapshot: mapSnapshot))
|
|
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 sortedSports: [Sport] {
|
|
trip.uniqueSports.sorted { $0.rawValue < $1.rawValue }
|
|
}
|
|
|
|
/// Map each unique city to its stadium name(s) from the stops.
|
|
private var cityStadiums: [String: String] {
|
|
var result: [String: String] = [:]
|
|
for city in trip.cities {
|
|
let stadiums = trip.stops
|
|
.filter { $0.city == city }
|
|
.compactMap { $0.stadium }
|
|
let unique = Array(Set(stadiums)).sorted()
|
|
if !unique.isEmpty {
|
|
result[city] = unique.joined(separator: " & ")
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
(theme.gradientColors.first ?? .black)
|
|
.ignoresSafeArea()
|
|
|
|
VStack(spacing: 0) {
|
|
// Header
|
|
Text(sortedSports.map { $0.displayName.uppercased() }.joined(separator: " + "))
|
|
.font(.system(size: 20, weight: .black))
|
|
.tracking(8)
|
|
.foregroundStyle(theme.secondaryTextColor)
|
|
.padding(.top, 20)
|
|
|
|
Text("ROAD TRIP")
|
|
.font(.system(size: 52, weight: .black))
|
|
.foregroundStyle(theme.textColor)
|
|
.padding(.top, 8)
|
|
|
|
Text(trip.formattedDateRange.uppercased())
|
|
.font(.system(size: 18, weight: .bold))
|
|
.tracking(2)
|
|
.foregroundStyle(theme.secondaryTextColor)
|
|
.padding(.top, 8)
|
|
|
|
Spacer()
|
|
|
|
// Map
|
|
if let mapSnapshot {
|
|
Image(uiImage: mapSnapshot)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.clipShape(RoundedRectangle(cornerRadius: 24))
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Stats
|
|
HStack(spacing: 40) {
|
|
statItem(value: String(format: "%.0f", trip.totalDistanceMiles), label: "MILES")
|
|
statItem(value: "\(trip.totalGames)", label: "GAMES")
|
|
statItem(value: "\(trip.cities.count)", label: "CITIES")
|
|
statItem(value: "\(trip.tripDuration)", label: "DAYS")
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// City trail
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(trip.cities.enumerated()), id: \.offset) { index, city in
|
|
HStack(spacing: 20) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(theme.accentColor)
|
|
.frame(width: 48, height: 48)
|
|
Text("\(index + 1)")
|
|
.font(.system(size: 22, weight: .black, design: .rounded))
|
|
.foregroundStyle(theme.gradientColors.first ?? .black)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(city.uppercased())
|
|
.font(.system(size: 32, weight: .black))
|
|
.foregroundStyle(theme.textColor)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.6)
|
|
|
|
if let stadium = cityStadiums[city] {
|
|
Text(stadium)
|
|
.font(.system(size: 18, weight: .medium))
|
|
.foregroundStyle(theme.secondaryTextColor)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.6)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
|
|
if index < trip.cities.count - 1 {
|
|
HStack(spacing: 20) {
|
|
Rectangle()
|
|
.fill(theme.textColor.opacity(0.15))
|
|
.frame(width: 2, height: 24)
|
|
.padding(.leading, 23)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 40)
|
|
|
|
Spacer()
|
|
|
|
TripCardAppFooter(theme: theme)
|
|
}
|
|
.padding(ShareCardDimensions.padding)
|
|
}
|
|
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
|
|
}
|
|
|
|
private func statItem(value: String, label: String) -> some View {
|
|
VStack(spacing: 6) {
|
|
Text(value)
|
|
.font(.system(size: 52, weight: .black, design: .rounded))
|
|
.foregroundStyle(theme.accentColor)
|
|
.minimumScaleFactor(0.6)
|
|
Text(label)
|
|
.font(.system(size: 16, weight: .bold))
|
|
.tracking(2)
|
|
.foregroundStyle(theme.secondaryTextColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - App Footer
|
|
|
|
private struct TripCardAppFooter: View {
|
|
let theme: ShareTheme
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
if let icon = Self.loadAppIcon() {
|
|
Image(uiImage: icon)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(width: 56, height: 56)
|
|
.clipShape(RoundedRectangle(cornerRadius: 13))
|
|
}
|
|
|
|
Text("SportsTime")
|
|
.font(.system(size: 18, weight: .bold))
|
|
.foregroundStyle(theme.textColor.opacity(0.5))
|
|
}
|
|
.padding(.bottom, 10)
|
|
}
|
|
|
|
private static func loadAppIcon() -> UIImage? {
|
|
if let icons = Bundle.main.infoDictionary?["CFBundleIcons"] as? [String: Any],
|
|
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
|
|
let files = primary["CFBundleIconFiles"] as? [String],
|
|
let name = files.last {
|
|
return UIImage(named: name)
|
|
}
|
|
return nil
|
|
}
|
|
}
|