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>
This commit is contained in:
Trey t
2026-02-09 14:55:53 -06:00
parent 1a7ce78ae4
commit 244ea5e107
16 changed files with 3441 additions and 748 deletions

View File

@@ -2,7 +2,8 @@
// TripCardGenerator.swift
// SportsTime
//
// Generates shareable trip summary cards with route map.
// Shareable trip cards unified design language.
// Solid color bg, plain white text, no panels/borders, app icon footer.
//
import SwiftUI
@@ -23,19 +24,12 @@ struct TripShareContent: ShareableContent {
theme: theme
)
let cardView = TripCardView(
trip: trip,
theme: theme,
mapSnapshot: mapSnapshot
)
let renderer = ImageRenderer(content: cardView)
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
}
}
@@ -47,80 +41,169 @@ private struct TripCardView: View {
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 sortedSports: [Sport] {
trip.uniqueSports.sorted { $0.rawValue < $1.rawValue }
}
private var primarySport: Sport? {
trip.uniqueSports.first
/// 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 {
ShareCardBackground(theme: theme, sports: trip.uniqueSports)
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
VStack(spacing: 40) {
ShareCardHeader(
title: sportTitle,
sport: primarySport,
theme: theme
)
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)
// 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))
Text("ROAD TRIP")
.font(.system(size: 52, weight: .black))
.foregroundStyle(theme.textColor)
.padding(.top, 8)
// 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
Text(trip.formattedDateRange.uppercased())
.font(.system(size: 18, weight: .bold))
.tracking(2)
.foregroundStyle(theme.secondaryTextColor)
.padding(.top, 8)
Spacer()
ShareCardFooter(theme: theme)
// 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
)
.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)
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
}
}