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:
559
SportsTime/Export/Sharing/TripDesignSamples.swift
Normal file
559
SportsTime/Export/Sharing/TripDesignSamples.swift
Normal file
@@ -0,0 +1,559 @@
|
||||
//
|
||||
// TripDesignSamples.swift
|
||||
// SportsTime
|
||||
//
|
||||
// 5 design explorations for trip summary cards.
|
||||
// All follow the unified design language: solid bg, no panels, app icon footer.
|
||||
//
|
||||
|
||||
#if DEBUG
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - Sample A: "Route Map Hero"
|
||||
// Large map dominates the card. Trip name at top. Stats + city trail below.
|
||||
|
||||
struct TripSampleA: View {
|
||||
let trip: Trip
|
||||
let theme: ShareTheme
|
||||
let mapSnapshot: UIImage?
|
||||
|
||||
private var sortedSports: [Sport] {
|
||||
trip.uniqueSports.sorted { $0.rawValue < $1.rawValue }
|
||||
}
|
||||
|
||||
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().frame(height: 30)
|
||||
|
||||
// Large map
|
||||
if let mapSnapshot {
|
||||
Image(uiImage: mapSnapshot)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 36)
|
||||
|
||||
// Stats
|
||||
HStack(spacing: 50) {
|
||||
statItem(value: String(format: "%.0f", trip.totalDistanceMiles), label: "MILES")
|
||||
statItem(value: "\(trip.totalGames)", label: "GAMES")
|
||||
statItem(value: "\(trip.cities.count)", label: "CITIES")
|
||||
}
|
||||
|
||||
Spacer().frame(height: 30)
|
||||
|
||||
// City trail
|
||||
Text(trip.cities.joined(separator: " \u{2192} ").uppercased())
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.6)
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
Spacer()
|
||||
|
||||
TripSampleAppFooter(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: 44, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.accentColor)
|
||||
Text(label)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.tracking(2)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample B: "City Trail"
|
||||
// City-to-city journey is the hero. Numbered stops with arrows.
|
||||
// Map smaller below. Stats compact at top.
|
||||
|
||||
struct TripSampleB: View {
|
||||
let trip: Trip
|
||||
let theme: ShareTheme
|
||||
let mapSnapshot: UIImage?
|
||||
|
||||
private var sortedSports: [Sport] {
|
||||
trip.uniqueSports.sorted { $0.rawValue < $1.rawValue }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(theme.gradientColors.first ?? .black)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
Text("ROAD TRIP")
|
||||
.font(.system(size: 20, weight: .black))
|
||||
.tracking(8)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
.padding(.top, 20)
|
||||
|
||||
Text(trip.name.uppercased())
|
||||
.font(.system(size: 44, weight: .black))
|
||||
.foregroundStyle(theme.textColor)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.6)
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer().frame(height: 36)
|
||||
|
||||
// City journey — vertical list
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(trip.cities.enumerated()), id: \.offset) { index, city in
|
||||
HStack(spacing: 20) {
|
||||
// Number badge
|
||||
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)
|
||||
}
|
||||
|
||||
Text(city.uppercased())
|
||||
.font(.system(size: 32, weight: .black))
|
||||
.foregroundStyle(theme.textColor)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if index < trip.cities.count - 1 {
|
||||
// Connector line
|
||||
HStack(spacing: 20) {
|
||||
Rectangle()
|
||||
.fill(theme.textColor.opacity(0.15))
|
||||
.frame(width: 2, height: 24)
|
||||
.padding(.leading, 23)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
Spacer().frame(height: 36)
|
||||
|
||||
// Stats row
|
||||
HStack(spacing: 50) {
|
||||
statItem(value: String(format: "%.0f", trip.totalDistanceMiles), label: "MILES")
|
||||
statItem(value: "\(trip.totalGames)", label: "GAMES")
|
||||
statItem(value: "\(trip.tripDuration)", label: "DAYS")
|
||||
}
|
||||
|
||||
Spacer().frame(height: 36)
|
||||
|
||||
// Map — smaller
|
||||
if let mapSnapshot {
|
||||
Image(uiImage: mapSnapshot)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.frame(height: 360)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
TripSampleAppFooter(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: 40, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.accentColor)
|
||||
Text(label)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.tracking(2)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample C: "Big Miles"
|
||||
// Total miles is the massive hero number. Map below. Cities + games as secondary.
|
||||
|
||||
struct TripSampleC: View {
|
||||
let trip: Trip
|
||||
let theme: ShareTheme
|
||||
let mapSnapshot: UIImage?
|
||||
|
||||
private var sortedSports: [Sport] {
|
||||
trip.uniqueSports.sorted { $0.rawValue < $1.rawValue }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(theme.gradientColors.first ?? .black)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Ghost miles number
|
||||
Text(String(format: "%.0f", trip.totalDistanceMiles))
|
||||
.font(.system(size: 260, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.textColor.opacity(0.04))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.3)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Sport + trip type
|
||||
HStack(spacing: 14) {
|
||||
ForEach(sortedSports.sorted(by: { $0.rawValue < $1.rawValue }), id: \.self) { sport in
|
||||
Image(systemName: sport.iconName)
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(theme.accentColor)
|
||||
}
|
||||
Text("ROAD TRIP")
|
||||
.font(.system(size: 18, weight: .black))
|
||||
.tracking(6)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
.padding(.top, 20)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Massive miles
|
||||
Text(String(format: "%.0f", trip.totalDistanceMiles))
|
||||
.font(.system(size: 160, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.textColor)
|
||||
.minimumScaleFactor(0.5)
|
||||
|
||||
Text("MILES DRIVEN")
|
||||
.font(.system(size: 22, weight: .black))
|
||||
.tracking(6)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
|
||||
Spacer().frame(height: 24)
|
||||
|
||||
// Secondary stats
|
||||
HStack(spacing: 50) {
|
||||
statItem(value: "\(trip.totalGames)", label: "GAMES")
|
||||
statItem(value: "\(trip.cities.count)", label: "CITIES")
|
||||
statItem(value: "\(trip.tripDuration)", label: "DAYS")
|
||||
}
|
||||
|
||||
Spacer().frame(height: 36)
|
||||
|
||||
// Map
|
||||
if let mapSnapshot {
|
||||
Image(uiImage: mapSnapshot)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
TripSampleAppFooter(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: 44, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.accentColor)
|
||||
Text(label)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.tracking(2)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample D: "Scoreboard"
|
||||
// Trip score front and center with letter grade. Map + stats below.
|
||||
// Feels like a game recap card.
|
||||
|
||||
struct TripSampleD: View {
|
||||
let trip: Trip
|
||||
let theme: ShareTheme
|
||||
let mapSnapshot: UIImage?
|
||||
|
||||
private var sortedSports: [Sport] {
|
||||
trip.uniqueSports.sorted { $0.rawValue < $1.rawValue }
|
||||
}
|
||||
|
||||
private var scoreGrade: String {
|
||||
trip.score?.scoreGrade ?? "A"
|
||||
}
|
||||
|
||||
private var scoreValue: String {
|
||||
trip.score?.formattedOverallScore ?? "85"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(theme.gradientColors.first ?? .black)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
Text(trip.name.uppercased())
|
||||
.font(.system(size: 20, weight: .black))
|
||||
.tracking(6)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
.padding(.top, 20)
|
||||
|
||||
Text("TRIP SCORE")
|
||||
.font(.system(size: 44, weight: .black))
|
||||
.foregroundStyle(theme.textColor)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Score circle
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(theme.textColor.opacity(0.1), lineWidth: 20)
|
||||
.frame(width: 320, height: 320)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: (Double(scoreValue) ?? 85) / 100)
|
||||
.stroke(theme.accentColor, style: StrokeStyle(lineWidth: 20, lineCap: .round))
|
||||
.frame(width: 320, height: 320)
|
||||
.rotationEffect(.degrees(-90))
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text(scoreGrade)
|
||||
.font(.system(size: 100, weight: .black))
|
||||
.foregroundStyle(theme.accentColor)
|
||||
Text(scoreValue + " / 100")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer().frame(height: 30)
|
||||
|
||||
// Date range
|
||||
Text(trip.formattedDateRange.uppercased())
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.tracking(2)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
|
||||
Spacer().frame(height: 30)
|
||||
|
||||
// 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().frame(height: 30)
|
||||
|
||||
// Map — compact
|
||||
if let mapSnapshot {
|
||||
Image(uiImage: mapSnapshot)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.frame(height: 340)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
TripSampleAppFooter(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: 36, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.accentColor)
|
||||
.minimumScaleFactor(0.6)
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.tracking(2)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample E: "Postcard"
|
||||
// Map takes most of the card like a postcard photo. Trip info overlaid at bottom.
|
||||
// Minimal text, maximum visual impact.
|
||||
|
||||
struct TripSampleE: View {
|
||||
let trip: Trip
|
||||
let theme: ShareTheme
|
||||
let mapSnapshot: UIImage?
|
||||
|
||||
private var sortedSports: [Sport] {
|
||||
trip.uniqueSports.sorted { $0.rawValue < $1.rawValue }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(theme.gradientColors.first ?? .black)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Tiny header
|
||||
HStack(spacing: 10) {
|
||||
ForEach(sortedSports.sorted(by: { $0.rawValue < $1.rawValue }), id: \.self) { sport in
|
||||
Image(systemName: sport.iconName)
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(theme.accentColor)
|
||||
}
|
||||
Text("ROAD TRIP")
|
||||
.font(.system(size: 16, weight: .black))
|
||||
.tracking(4)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
Spacer()
|
||||
Text(trip.formattedDateRange.uppercased())
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
// Giant map
|
||||
if let mapSnapshot {
|
||||
Image(uiImage: mapSnapshot)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 30)
|
||||
|
||||
// Trip name big
|
||||
Text(trip.cities.joined(separator: " \u{2192} ").uppercased())
|
||||
.font(.system(size: 36, weight: .black))
|
||||
.foregroundStyle(theme.textColor)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
.minimumScaleFactor(0.5)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Spacer().frame(height: 24)
|
||||
|
||||
// Stats inline
|
||||
HStack(spacing: 30) {
|
||||
inlineStat(value: String(format: "%.0f", trip.totalDistanceMiles), label: "mi")
|
||||
Text("\u{2022}")
|
||||
.foregroundStyle(theme.textColor.opacity(0.2))
|
||||
inlineStat(value: "\(trip.totalGames)", label: "games")
|
||||
Text("\u{2022}")
|
||||
.foregroundStyle(theme.textColor.opacity(0.2))
|
||||
inlineStat(value: "\(trip.tripDuration)", label: "days")
|
||||
Text("\u{2022}")
|
||||
.foregroundStyle(theme.textColor.opacity(0.2))
|
||||
inlineStat(value: "\(trip.cities.count)", label: "cities")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
TripSampleAppFooter(theme: theme)
|
||||
}
|
||||
.padding(ShareCardDimensions.padding)
|
||||
}
|
||||
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
|
||||
}
|
||||
|
||||
private func inlineStat(value: String, label: String) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(value)
|
||||
.font(.system(size: 28, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.accentColor)
|
||||
Text(label)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared Footer
|
||||
|
||||
private struct TripSampleAppFooter: 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
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user