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

@@ -303,8 +303,8 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTimeDebug.entitlements; CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTimeDebug.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@@ -339,8 +339,8 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTime.entitlements; CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTime.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;

View File

@@ -177,29 +177,20 @@ struct SportProgressButton: View {
Button(action: action) { Button(action: action) {
VStack(spacing: 6) { VStack(spacing: 6) {
ZStack { ZStack {
// Background circle with progress ring
Circle() Circle()
.stroke(sport.themeColor.opacity(0.2), lineWidth: 3) .fill(isSelected ? sport.themeColor.opacity(0.08) : Color.clear)
.frame(width: 48, height: 48) .frame(width: 48, height: 48)
Circle() if isSelected {
.trim(from: 0, to: progress) Circle()
.stroke(sport.themeColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .stroke(sport.themeColor, lineWidth: 3)
.frame(width: 48, height: 48) .frame(width: 48, height: 48)
.rotationEffect(.degrees(-90)) }
// Sport icon
Image(systemName: sport.iconName) Image(systemName: sport.iconName)
.font(.title3) .font(.title3)
.foregroundStyle(isSelected ? sport.themeColor : Theme.textMuted(colorScheme)) .foregroundStyle(isSelected ? sport.themeColor : Theme.textMuted(colorScheme))
} }
.overlay {
if isSelected {
Circle()
.stroke(sport.themeColor, lineWidth: 2)
.frame(width: 54, height: 54)
}
}
Text(sport.rawValue) Text(sport.rawValue)
.font(.caption2) .font(.caption2)

View File

@@ -2,7 +2,9 @@
// AchievementCardGenerator.swift // AchievementCardGenerator.swift
// SportsTime // SportsTime
// //
// Generates shareable achievement cards: spotlight, collection, milestone, context. // Shareable achievement cards unified design language.
// Solid color bg, ghost text, rounded-square badge with gold stroke,
// plain white text (no panels/borders), app icon footer.
// //
import SwiftUI import SwiftUI
@@ -17,18 +19,12 @@ struct AchievementSpotlightContent: ShareableContent {
@MainActor @MainActor
func render(theme: ShareTheme) async throws -> UIImage { func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementSpotlightView( let renderer = ImageRenderer(content: AchievementSpotlightView(achievement: achievement, theme: theme))
achievement: achievement,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0 renderer.scale = 3.0
guard let image = renderer.uiImage else { guard let image = renderer.uiImage else {
throw ShareError.renderingFailed throw ShareError.renderingFailed
} }
return image return image
} }
} }
@@ -38,82 +34,25 @@ struct AchievementSpotlightContent: ShareableContent {
struct AchievementCollectionContent: ShareableContent { struct AchievementCollectionContent: ShareableContent {
let achievements: [AchievementProgress] let achievements: [AchievementProgress]
let year: Int let year: Int
var sports: Set<Sport> = [] // Sports for background icons var sports: Set<Sport> = []
var filterSport: Sport? = nil // The sport filter applied (for header title) var filterSport: Sport? = nil
var cardType: ShareCardType { .achievementCollection } var cardType: ShareCardType { .achievementCollection }
@MainActor @MainActor
func render(theme: ShareTheme) async throws -> UIImage { func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementCollectionView( let renderer = ImageRenderer(content: AchievementCollectionView(
achievements: achievements, achievements: achievements,
year: year, year: year,
sports: sports, sports: sports,
filterSport: filterSport, filterSport: filterSport,
theme: theme theme: theme
) ))
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0 renderer.scale = 3.0
guard let image = renderer.uiImage else { guard let image = renderer.uiImage else {
throw ShareError.renderingFailed throw ShareError.renderingFailed
} }
return image
}
}
// MARK: - Achievement Milestone Content
struct AchievementMilestoneContent: ShareableContent {
let achievement: AchievementProgress
var cardType: ShareCardType { .achievementMilestone }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementMilestoneView(
achievement: achievement,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
// MARK: - Achievement Context Content
struct AchievementContextContent: ShareableContent {
let achievement: AchievementProgress
let tripName: String?
let mapSnapshot: UIImage?
var cardType: ShareCardType { .achievementContext }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementContextView(
achievement: achievement,
tripName: tripName,
mapSnapshot: mapSnapshot,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image return image
} }
} }
@@ -124,61 +63,61 @@ private struct AchievementSpotlightView: View {
let achievement: AchievementProgress let achievement: AchievementProgress
let theme: ShareTheme let theme: ShareTheme
private var sports: Set<Sport> {
if let sport = achievement.definition.sport {
return [sport]
}
return []
}
var body: some View { var body: some View {
ZStack { ZStack {
ShareCardBackground(theme: theme, sports: sports) (theme.gradientColors.first ?? .black)
.ignoresSafeArea()
VStack(spacing: 50) { Text(achievement.definition.name.uppercased())
.font(.system(size: 90, weight: .black))
.foregroundStyle(theme.textColor.opacity(0.07))
.multilineTextAlignment(.center)
.lineLimit(4)
.minimumScaleFactor(0.4)
.padding(.horizontal, 20)
VStack(spacing: 0) {
Spacer() Spacer()
// Badge
AchievementBadge( AchievementBadge(
definition: achievement.definition, definition: achievement.definition,
size: 400 isEarned: achievement.earnedAt != nil,
size: 360
) )
// Name Spacer().frame(height: 44)
Text(achievement.definition.name)
.font(.system(size: 56, weight: .bold, design: .rounded)) Text(achievement.definition.name.uppercased())
.font(.system(size: 52, weight: .black))
.foregroundStyle(theme.textColor) .foregroundStyle(theme.textColor)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true) .lineLimit(3)
.minimumScaleFactor(0.6)
.padding(.horizontal, 60)
Spacer().frame(height: 16)
// Description
Text(achievement.definition.description) Text(achievement.definition.description)
.font(.system(size: 28)) .font(.system(size: 22, weight: .medium))
.foregroundStyle(theme.secondaryTextColor) .foregroundStyle(theme.secondaryTextColor)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 80) .padding(.horizontal, 80)
// Unlock date if let date = achievement.earnedAt {
if let earnedAt = achievement.earnedAt { Text(date.formatted(date: .abbreviated, time: .omitted).uppercased())
HStack(spacing: 8) { .font(.system(size: 18, weight: .bold))
Image(systemName: "checkmark.circle.fill") .tracking(2)
.foregroundStyle(theme.accentColor) .foregroundStyle(theme.secondaryTextColor)
Text("Unlocked \(earnedAt.formatted(date: .abbreviated, time: .omitted))") .padding(.top, 14)
}
.font(.system(size: 24))
.foregroundStyle(theme.secondaryTextColor)
} }
Spacer() Spacer()
ShareCardFooter(theme: theme) AchievementCardAppFooter(theme: theme)
} }
.padding(ShareCardDimensions.padding) .padding(ShareCardDimensions.padding)
} }
.frame( .frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
} }
} }
@@ -192,212 +131,69 @@ private struct AchievementCollectionView: View {
let theme: ShareTheme let theme: ShareTheme
private let columns = [ private let columns = [
GridItem(.flexible(), spacing: 30), GridItem(.flexible(), spacing: 24),
GridItem(.flexible(), spacing: 30), GridItem(.flexible(), spacing: 24),
GridItem(.flexible(), spacing: 30) GridItem(.flexible(), spacing: 24)
] ]
private var headerTitle: String { private var sportLabel: String {
if let sport = filterSport { filterSport?.displayName.uppercased() ?? "ALL SPORTS"
return "My \(String(year)) \(sport.rawValue) Achievements"
}
return "My \(String(year)) Achievements"
} }
var body: some View { var body: some View {
ZStack { ZStack {
ShareCardBackground(theme: theme, sports: sports) (theme.gradientColors.first ?? .black)
.ignoresSafeArea()
VStack(spacing: 40) { VStack(spacing: 0) {
// Header // Header
Text(headerTitle) Text(sportLabel)
.font(.system(size: 48, weight: .bold, design: .rounded)) .font(.system(size: 20, weight: .black))
.tracking(8)
.foregroundStyle(theme.secondaryTextColor)
.padding(.top, 20)
Text("ACHIEVEMENTS")
.font(.system(size: 44, weight: .black))
.foregroundStyle(theme.textColor) .foregroundStyle(theme.textColor)
.padding(.top, 8)
Text("\(achievements.count) UNLOCKED \u{2022} \(year)")
.font(.system(size: 18, weight: .bold))
.tracking(2)
.foregroundStyle(theme.accentColor)
.padding(.top, 8)
Spacer() Spacer()
// Grid // Badge grid
LazyVGrid(columns: columns, spacing: 40) { LazyVGrid(columns: columns, spacing: 28) {
ForEach(achievements.prefix(12)) { achievement in ForEach(Array(achievements.prefix(9).enumerated()), id: \.offset) { _, item in
VStack(spacing: 12) { VStack(spacing: 12) {
AchievementBadge( AchievementBadge(
definition: achievement.definition, definition: item.definition,
size: 200 isEarned: item.earnedAt != nil,
size: 180
) )
Text(achievement.definition.name) Text(item.definition.name)
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 18, weight: .bold))
.foregroundStyle(theme.textColor) .foregroundStyle(theme.textColor)
.lineLimit(2)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(2)
.frame(height: 44)
} }
} }
} }
.padding(.horizontal, 40) .padding(.horizontal, 30)
Spacer() Spacer()
// Count AchievementCardAppFooter(theme: theme)
Text("\(achievements.count) achievements unlocked")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
ShareCardFooter(theme: theme)
} }
.padding(ShareCardDimensions.padding) .padding(ShareCardDimensions.padding)
} }
.frame( .frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
}
}
// MARK: - Milestone View
private struct AchievementMilestoneView: View {
let achievement: AchievementProgress
let theme: ShareTheme
private let goldColor = Color(hex: "FFD700")
private var sports: Set<Sport> {
if let sport = achievement.definition.sport {
return [sport]
}
return []
}
var body: some View {
ZStack {
ShareCardBackground(theme: theme, sports: sports)
// Confetti burst pattern
ConfettiBurst()
.opacity(0.3)
VStack(spacing: 40) {
Spacer()
// Milestone label
Text("MILESTONE")
.font(.system(size: 24, weight: .black, design: .rounded))
.tracking(4)
.foregroundStyle(goldColor)
// Large badge
AchievementBadge(
definition: achievement.definition,
size: 500
)
.overlay {
Circle()
.stroke(goldColor, lineWidth: 4)
.frame(width: 520, height: 520)
}
// Name
Text(achievement.definition.name)
.font(.system(size: 56, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
// Description
Text(achievement.definition.description)
.font(.system(size: 28))
.foregroundStyle(theme.secondaryTextColor)
.multilineTextAlignment(.center)
.padding(.horizontal, 80)
Spacer()
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
}
}
// MARK: - Context View
private struct AchievementContextView: View {
let achievement: AchievementProgress
let tripName: String?
let mapSnapshot: UIImage?
let theme: ShareTheme
private var sports: Set<Sport> {
if let sport = achievement.definition.sport {
return [sport]
}
return []
}
var body: some View {
ZStack {
ShareCardBackground(theme: theme, sports: sports)
VStack(spacing: 40) {
// Header with badge and name
HStack(spacing: 24) {
AchievementBadge(
definition: achievement.definition,
size: 150
)
VStack(alignment: .leading, spacing: 8) {
Text(achievement.definition.name)
.font(.system(size: 40, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
Text("Unlocked!")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(theme.accentColor)
}
}
.padding(.top, 40)
Spacer()
// Context map or placeholder
if let snapshot = mapSnapshot {
Image(uiImage: snapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 960, maxHeight: 700)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(theme.accentColor.opacity(0.3), lineWidth: 2)
}
}
// Trip name
if let tripName = tripName {
Text("Unlocked during my")
.font(.system(size: 24))
.foregroundStyle(theme.secondaryTextColor)
Text(tripName)
.font(.system(size: 32, weight: .semibold))
.foregroundStyle(theme.textColor)
}
Spacer()
ShareCardFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
} }
} }
@@ -405,57 +201,81 @@ private struct AchievementContextView: View {
private struct AchievementBadge: View { private struct AchievementBadge: View {
let definition: AchievementDefinition let definition: AchievementDefinition
let isEarned: Bool
let size: CGFloat let size: CGFloat
private let gold = Color(hex: "FFD700")
private let goldDark = Color(hex: "B8860B")
var body: some View { var body: some View {
ZStack { ZStack {
Circle() RoundedRectangle(cornerRadius: size * 0.22)
.fill(definition.iconColor.opacity(0.2)) .fill(
LinearGradient(
colors: [definition.iconColor.opacity(0.3), definition.iconColor.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: size, height: size) .frame(width: size, height: size)
Circle() RoundedRectangle(cornerRadius: size * 0.22)
.stroke(definition.iconColor, lineWidth: size * 0.02) .stroke(
.frame(width: size * 0.9, height: size * 0.9) LinearGradient(colors: [gold, goldDark], startPoint: .topLeading, endPoint: .bottomTrailing),
lineWidth: 3
)
.frame(width: size, height: size)
Image(systemName: definition.iconName) Image(systemName: definition.iconName)
.font(.system(size: size * 0.4)) .font(.system(size: size * 0.42, weight: .bold))
.foregroundStyle(definition.iconColor) .foregroundStyle(definition.iconColor)
}
}
}
// MARK: - Confetti Burst
private struct ConfettiBurst: View {
var body: some View {
GeometryReader { geometry in
let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height * 0.4)
ForEach(0..<24, id: \.self) { index in
let angle = Double(index) * (360.0 / 24.0)
let distance: CGFloat = CGFloat.random(in: 200...400)
let xOffset = cos(angle * .pi / 180) * distance
let yOffset = sin(angle * .pi / 180) * distance
if isEarned {
Circle() Circle()
.fill(confettiColor(for: index)) .fill(gold)
.frame(width: CGFloat.random(in: 8...20)) .frame(width: size * 0.17, height: size * 0.17)
.position( .overlay {
x: center.x + xOffset, Image(systemName: "checkmark")
y: center.y + yOffset .font(.system(size: size * 0.078, weight: .black))
) .foregroundStyle(Color.black.opacity(0.75))
}
.offset(x: size * 0.35, y: -size * 0.35)
} }
} }
} .frame(width: size, height: size)
.shadow(color: gold.opacity(0.3), radius: 12, y: 6)
private func confettiColor(for index: Int) -> Color { }
let colors: [Color] = [ }
Color(hex: "FFD700"),
Color(hex: "FF6B35"), // MARK: - App Footer
Color(hex: "00D4FF"),
Color(hex: "95D5B2"), private struct AchievementCardAppFooter: View {
Color(hex: "FF85A1") let theme: ShareTheme
]
return colors[index % colors.count] 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
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,8 @@
// ProgressCardGenerator.swift // ProgressCardGenerator.swift
// SportsTime // SportsTime
// //
// Generates shareable stadium progress cards. // Shareable progress cards unified design language.
// Solid color bg, progress ring with fraction, plain white text, app icon footer.
// //
import SwiftUI import SwiftUI
@@ -25,20 +26,17 @@ struct ProgressShareContent: ShareableContent {
theme: theme theme: theme
) )
let cardView = ProgressCardView( let renderer = ImageRenderer(content: ProgressCardView(
progress: progress, progress: progress,
tripCount: tripCount, tripCount: tripCount,
theme: theme, theme: theme,
mapSnapshot: mapSnapshot mapSnapshot: mapSnapshot
) ))
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0 renderer.scale = 3.0
guard let image = renderer.uiImage else { guard let image = renderer.uiImage else {
throw ShareError.renderingFailed throw ShareError.renderingFailed
} }
return image return image
} }
} }
@@ -51,62 +49,146 @@ private struct ProgressCardView: View {
let theme: ShareTheme let theme: ShareTheme
let mapSnapshot: UIImage? let mapSnapshot: UIImage?
private let gold = Color(hex: "FFD700")
private var isComplete: Bool {
progress.completionPercentage >= 100
}
private var accent: Color {
isComplete ? gold : theme.accentColor
}
private var remaining: Int {
max(0, progress.totalStadiums - progress.visitedStadiums)
}
var body: some View { var body: some View {
ZStack { ZStack {
ShareCardBackground(theme: theme, sports: [progress.sport]) (theme.gradientColors.first ?? .black)
.ignoresSafeArea()
VStack(spacing: 40) { VStack(spacing: 0) {
ShareCardHeader( // Header
title: "\(progress.sport.displayName) Stadium Quest", Text(progress.sport.displayName.uppercased())
sport: progress.sport, .font(.system(size: 20, weight: .black))
theme: theme .tracking(8)
)
Spacer()
// Progress ring
ShareProgressRing(
current: progress.visitedStadiums,
total: progress.totalStadiums,
theme: theme
)
Text("\(Int(progress.completionPercentage))% Complete")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(theme.secondaryTextColor) .foregroundStyle(theme.secondaryTextColor)
.padding(.top, 20)
// Stats row Text("STADIUM QUEST")
ShareStatsRow( .font(.system(size: 44, weight: .black))
stats: [ .foregroundStyle(theme.textColor)
(value: "\(progress.visitedStadiums)", label: "visited"), .padding(.top, 8)
(value: "\(progress.totalStadiums - progress.visitedStadiums)", label: "remain"),
(value: "\(tripCount)", label: "trips")
],
theme: theme
)
// Map if isComplete {
if let snapshot = mapSnapshot { Text("COMPLETE")
Image(uiImage: snapshot) .font(.system(size: 18, weight: .black))
.resizable() .tracking(4)
.aspectRatio(contentMode: .fit) .foregroundStyle(gold)
.frame(maxWidth: 960) .padding(.top, 6)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(theme.accentColor.opacity(0.3), lineWidth: 2)
}
} }
Spacer() Spacer()
ShareCardFooter(theme: theme) // Progress ring with fraction inside
ZStack {
Circle()
.stroke(theme.textColor.opacity(0.1), lineWidth: 24)
.frame(width: 400, height: 400)
Circle()
.trim(from: 0, to: progress.completionPercentage / 100)
.stroke(accent, style: StrokeStyle(lineWidth: 24, lineCap: .round))
.frame(width: 400, height: 400)
.rotationEffect(.degrees(-90))
VStack(spacing: 8) {
Text("\(progress.visitedStadiums)")
.font(.system(size: 120, weight: .black, design: .rounded))
.foregroundStyle(accent)
Rectangle()
.fill(theme.textColor.opacity(0.2))
.frame(width: 140, height: 3)
Text("\(progress.totalStadiums)")
.font(.system(size: 60, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor.opacity(0.4))
}
}
Spacer().frame(height: 40)
// Stats
HStack(spacing: 50) {
statItem(value: "\(progress.visitedStadiums)", label: "VISITED")
statItem(value: "\(remaining)", label: "TO GO")
statItem(value: "\(tripCount)", label: "TRIPS")
}
Spacer().frame(height: 40)
// Map
if let mapSnapshot {
Image(uiImage: mapSnapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 24))
.padding(.horizontal, 16)
}
Spacer()
ProgressCardAppFooter(theme: theme)
} }
.padding(ShareCardDimensions.padding) .padding(ShareCardDimensions.padding)
} }
.frame( .frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
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(accent)
Text(label)
.font(.system(size: 14, weight: .bold))
.tracking(2)
.foregroundStyle(theme.secondaryTextColor)
}
}
}
// MARK: - App Footer
private struct ProgressCardAppFooter: 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
} }
} }

View File

@@ -0,0 +1,546 @@
//
// ProgressDesignSamples.swift
// SportsTime
//
// 5 design explorations for stadium progress cards.
// All follow the unified design language: solid bg, ghost text, no panels, app icon footer.
//
#if DEBUG
import SwiftUI
import UIKit
// MARK: - Sample A: "Ring Center"
// Giant progress ring centered. Percentage inside the ring.
// Sport name above, visited/remaining/trips stats below as plain text.
struct ProgressSampleA: View {
let progress: LeagueProgress
let tripCount: Int
let theme: ShareTheme
let mapSnapshot: UIImage?
private let gold = Color(hex: "FFD700")
private var isComplete: Bool { progress.completionPercentage >= 100 }
private var accent: Color { isComplete ? gold : theme.accentColor }
private var remaining: Int { max(0, progress.totalStadiums - progress.visitedStadiums) }
var body: some View {
ZStack {
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
// Ghost percentage
Text("\(Int(progress.completionPercentage))%")
.font(.system(size: 300, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor.opacity(0.05))
VStack(spacing: 0) {
Text(progress.sport.displayName.uppercased())
.font(.system(size: 20, weight: .black))
.tracking(8)
.foregroundStyle(theme.secondaryTextColor)
.padding(.top, 20)
Text("STADIUM QUEST")
.font(.system(size: 44, weight: .black))
.foregroundStyle(theme.textColor)
.padding(.top, 8)
Spacer()
// Ring
ZStack {
Circle()
.stroke(theme.textColor.opacity(0.1), lineWidth: 24)
.frame(width: 400, height: 400)
Circle()
.trim(from: 0, to: progress.completionPercentage / 100)
.stroke(accent, style: StrokeStyle(lineWidth: 24, lineCap: .round))
.frame(width: 400, height: 400)
.rotationEffect(.degrees(-90))
VStack(spacing: 4) {
Text("\(progress.visitedStadiums)")
.font(.system(size: 108, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor)
Text("of \(progress.totalStadiums)")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
}
}
Spacer().frame(height: 50)
// Stats as plain text
HStack(spacing: 50) {
statItem(value: "\(progress.visitedStadiums)", label: "VISITED")
statItem(value: "\(remaining)", label: "TO GO")
statItem(value: "\(tripCount)", label: "TRIPS")
}
Spacer().frame(height: 40)
// Map
if let mapSnapshot {
Image(uiImage: mapSnapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 20))
.frame(height: 350)
.padding(.horizontal, 20)
}
Spacer()
ProgressSampleAppFooter(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(accent)
Text(label)
.font(.system(size: 14, weight: .bold))
.tracking(2)
.foregroundStyle(theme.secondaryTextColor)
}
}
}
// MARK: - Sample B: "Big Number"
// Massive percentage dominates the card. Thin horizontal progress bar.
// Sport icon + name at top. Minimal stats.
struct ProgressSampleB: View {
let progress: LeagueProgress
let tripCount: Int
let theme: ShareTheme
let mapSnapshot: UIImage?
private let gold = Color(hex: "FFD700")
private var isComplete: Bool { progress.completionPercentage >= 100 }
private var accent: Color { isComplete ? gold : theme.accentColor }
private var remaining: Int { max(0, progress.totalStadiums - progress.visitedStadiums) }
var body: some View {
ZStack {
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
// Ghost sport name
Text(progress.sport.displayName.uppercased())
.font(.system(size: 140, weight: .black))
.foregroundStyle(theme.textColor.opacity(0.04))
VStack(spacing: 0) {
// Sport icon + name
HStack(spacing: 14) {
Image(systemName: progress.sport.iconName)
.font(.system(size: 28, weight: .bold))
.foregroundStyle(accent)
Text(progress.sport.displayName.uppercased())
.font(.system(size: 20, weight: .black))
.tracking(6)
.foregroundStyle(theme.secondaryTextColor)
}
.padding(.top, 20)
Spacer()
// Massive percentage
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(Int(progress.completionPercentage))")
.font(.system(size: 200, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor)
.minimumScaleFactor(0.5)
Text("%")
.font(.system(size: 72, weight: .black, design: .rounded))
.foregroundStyle(accent)
}
Spacer().frame(height: 20)
// Thin progress bar
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(theme.textColor.opacity(0.1))
.frame(height: 8)
Capsule()
.fill(accent)
.frame(width: geo.size.width * progress.completionPercentage / 100, height: 8)
}
}
.frame(height: 8)
.padding(.horizontal, 60)
Spacer().frame(height: 24)
Text("\(progress.visitedStadiums) of \(progress.totalStadiums) stadiums")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(theme.secondaryTextColor)
Spacer().frame(height: 40)
// Map
if let mapSnapshot {
Image(uiImage: mapSnapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 20))
.frame(height: 380)
.padding(.horizontal, 20)
}
Spacer()
ProgressSampleAppFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
}
}
// MARK: - Sample C: "Fraction Hero"
// "15 of 30" as the hero element, big and centered.
// Small ring to the side. Map below. Clean and focused.
struct ProgressSampleC: View {
let progress: LeagueProgress
let tripCount: Int
let theme: ShareTheme
let mapSnapshot: UIImage?
private let gold = Color(hex: "FFD700")
private var isComplete: Bool { progress.completionPercentage >= 100 }
private var accent: Color { isComplete ? gold : theme.accentColor }
private var remaining: Int { max(0, progress.totalStadiums - progress.visitedStadiums) }
var body: some View {
ZStack {
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
// Ghost visited count
Text("\(progress.visitedStadiums)")
.font(.system(size: 400, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor.opacity(0.04))
VStack(spacing: 0) {
Text("STADIUM QUEST")
.font(.system(size: 18, weight: .black))
.tracking(6)
.foregroundStyle(theme.secondaryTextColor)
.padding(.top, 20)
Spacer()
// Hero fraction
VStack(spacing: 8) {
Text("\(progress.visitedStadiums)")
.font(.system(size: 160, weight: .black, design: .rounded))
.foregroundStyle(accent)
Rectangle()
.fill(theme.textColor.opacity(0.2))
.frame(width: 200, height: 3)
Text("\(progress.totalStadiums)")
.font(.system(size: 80, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor.opacity(0.4))
}
Spacer().frame(height: 20)
Text(progress.sport.displayName.uppercased() + " STADIUMS")
.font(.system(size: 22, weight: .black))
.tracking(4)
.foregroundStyle(theme.secondaryTextColor)
if isComplete {
Text("COMPLETE")
.font(.system(size: 20, weight: .black))
.tracking(6)
.foregroundStyle(gold)
.padding(.top, 10)
}
Spacer().frame(height: 40)
// Map
if let mapSnapshot {
Image(uiImage: mapSnapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 20))
.frame(height: 380)
.padding(.horizontal, 20)
}
Spacer()
ProgressSampleAppFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
}
}
// MARK: - Sample D: "Map Hero"
// Map takes center stage, large. Progress info above and below.
// Sport icon badge at top.
struct ProgressSampleD: View {
let progress: LeagueProgress
let tripCount: Int
let theme: ShareTheme
let mapSnapshot: UIImage?
private let gold = Color(hex: "FFD700")
private var isComplete: Bool { progress.completionPercentage >= 100 }
private var accent: Color { isComplete ? gold : theme.accentColor }
private var remaining: Int { max(0, progress.totalStadiums - progress.visitedStadiums) }
var body: some View {
ZStack {
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
// Ghost sport name
Text(progress.sport.rawValue)
.font(.system(size: 200, weight: .black))
.foregroundStyle(theme.textColor.opacity(0.04))
.rotationEffect(.degrees(-15))
VStack(spacing: 0) {
// Sport icon badge + title
Image(systemName: progress.sport.iconName)
.font(.system(size: 44, weight: .bold))
.foregroundStyle(accent)
.padding(.top, 20)
Text(progress.sport.displayName.uppercased())
.font(.system(size: 20, weight: .black))
.tracking(6)
.foregroundStyle(theme.secondaryTextColor)
.padding(.top, 10)
// Percentage
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(Int(progress.completionPercentage))")
.font(.system(size: 80, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor)
Text("% COMPLETE")
.font(.system(size: 22, weight: .black))
.tracking(2)
.foregroundStyle(theme.secondaryTextColor)
}
.padding(.top, 16)
Spacer().frame(height: 30)
// Large map
if let mapSnapshot {
Image(uiImage: mapSnapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 24))
.padding(.horizontal, 16)
} else {
RoundedRectangle(cornerRadius: 24)
.fill(theme.textColor.opacity(0.06))
.frame(height: 600)
.overlay {
Image(systemName: "map")
.font(.system(size: 48))
.foregroundStyle(theme.secondaryTextColor)
}
.padding(.horizontal, 16)
}
Spacer().frame(height: 30)
// Stats below map
HStack(spacing: 50) {
VStack(spacing: 4) {
Text("\(progress.visitedStadiums)")
.font(.system(size: 40, weight: .black, design: .rounded))
.foregroundStyle(accent)
Text("VISITED")
.font(.system(size: 13, weight: .bold))
.tracking(2)
.foregroundStyle(theme.secondaryTextColor)
}
VStack(spacing: 4) {
Text("\(remaining)")
.font(.system(size: 40, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor)
Text("TO GO")
.font(.system(size: 13, weight: .bold))
.tracking(2)
.foregroundStyle(theme.secondaryTextColor)
}
VStack(spacing: 4) {
Text("\(tripCount)")
.font(.system(size: 40, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor)
Text("TRIPS")
.font(.system(size: 13, weight: .bold))
.tracking(2)
.foregroundStyle(theme.secondaryTextColor)
}
}
Spacer()
ProgressSampleAppFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
}
}
// MARK: - Sample E: "Countdown"
// Focus on what's LEFT: "15 TO GO" is the hero.
// Visited shown smaller. Optimistic, forward-looking tone.
struct ProgressSampleE: View {
let progress: LeagueProgress
let tripCount: Int
let theme: ShareTheme
let mapSnapshot: UIImage?
private let gold = Color(hex: "FFD700")
private var isComplete: Bool { progress.completionPercentage >= 100 }
private var accent: Color { isComplete ? gold : theme.accentColor }
private var remaining: Int { max(0, progress.totalStadiums - progress.visitedStadiums) }
var body: some View {
ZStack {
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
// Ghost remaining number
Text("\(remaining)")
.font(.system(size: 400, weight: .black, design: .rounded))
.foregroundStyle(accent.opacity(0.06))
VStack(spacing: 0) {
HStack(spacing: 14) {
Image(systemName: progress.sport.iconName)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(accent)
Text(progress.sport.displayName.uppercased())
.font(.system(size: 18, weight: .black))
.tracking(4)
.foregroundStyle(theme.secondaryTextColor)
}
.padding(.top, 20)
Spacer()
if isComplete {
// Complete state
Text("ALL")
.font(.system(size: 48, weight: .black))
.tracking(8)
.foregroundStyle(gold)
Text("\(progress.totalStadiums)")
.font(.system(size: 160, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor)
Text("STADIUMS VISITED")
.font(.system(size: 24, weight: .black))
.tracking(4)
.foregroundStyle(gold)
} else {
// Countdown state
Text("\(remaining)")
.font(.system(size: 180, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor)
Text("STADIUMS TO GO")
.font(.system(size: 24, weight: .black))
.tracking(4)
.foregroundStyle(theme.secondaryTextColor)
Spacer().frame(height: 20)
Text("\(progress.visitedStadiums) of \(progress.totalStadiums) visited")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(accent)
}
Spacer().frame(height: 40)
// Map
if let mapSnapshot {
Image(uiImage: mapSnapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 20))
.frame(height: 380)
.padding(.horizontal, 20)
}
Spacer()
ProgressSampleAppFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
}
}
// MARK: - Shared Footer
private struct ProgressSampleAppFooter: 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

View File

@@ -2,7 +2,7 @@
// ShareCardComponents.swift // ShareCardComponents.swift
// SportsTime // SportsTime
// //
// Reusable components for share cards: header, footer, stats row, map snapshot. // Shared building blocks for the new shareable card system.
// //
import SwiftUI import SwiftUI
@@ -16,15 +16,7 @@ struct ShareCardBackground: View {
var sports: Set<Sport>? = nil var sports: Set<Sport>? = nil
var body: some View { var body: some View {
if let sports = sports, !sports.isEmpty { ShareCardSportBackground(sports: sports ?? [], theme: theme)
ShareCardSportBackground(sports: sports, theme: theme)
} else {
LinearGradient(
colors: theme.gradientColors,
startPoint: .top,
endPoint: .bottom
)
}
} }
} }
@@ -36,24 +28,62 @@ struct ShareCardHeader: View {
let theme: ShareTheme let theme: ShareTheme
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(alignment: .leading, spacing: 16) {
if let sport = sport { HStack(alignment: .center, spacing: 18) {
ZStack { if let sport = sport {
Circle() ZStack {
.fill(theme.accentColor.opacity(0.2)) RoundedRectangle(cornerRadius: 20)
.frame(width: 80, height: 80) .fill(theme.accentColor.opacity(0.22))
.frame(width: 88, height: 88)
Image(systemName: sport.iconName) RoundedRectangle(cornerRadius: 20)
.font(.system(size: 40)) .stroke(theme.accentColor.opacity(0.65), lineWidth: 1.5)
.foregroundStyle(theme.accentColor) .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)
} }
Text(title) Capsule()
.font(.system(size: 48, weight: .bold, design: .rounded)) .fill(
.foregroundStyle(theme.textColor) LinearGradient(
.multilineTextAlignment(.center) 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)
)
)
} }
} }
@@ -63,19 +93,32 @@ struct ShareCardFooter: View {
let theme: ShareTheme let theme: ShareTheme
var body: some View { var body: some View {
VStack(spacing: 12) { HStack(spacing: 14) {
HStack(spacing: 8) { Text("SPORTSTIME")
Image(systemName: "sportscourt.fill") .font(.system(size: 16, weight: .black))
.font(.system(size: 20)) .tracking(3.5)
Text("SportsTime") .foregroundStyle(theme.accentColor)
.font(.system(size: 24, weight: .semibold))
}
.foregroundStyle(theme.accentColor)
Text("Plan your stadium adventure") Capsule()
.font(.system(size: 18)) .fill(theme.borderColor)
.frame(width: 26, height: 2)
Text("build your next game-day route")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(theme.secondaryTextColor) .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)
)
)
} }
} }
@@ -86,25 +129,33 @@ struct ShareStatsRow: View {
let theme: ShareTheme let theme: ShareTheme
var body: some View { var body: some View {
HStack(spacing: 60) { HStack(spacing: 14) {
ForEach(Array(stats.enumerated()), id: \.offset) { _, stat in ForEach(Array(stats.enumerated()), id: \.offset) { _, stat in
VStack(spacing: 8) { VStack(spacing: 7) {
Text(stat.value) Text(stat.value)
.font(.system(size: 36, weight: .bold, design: .rounded)) .font(.system(size: 44, weight: .black, design: .rounded))
.foregroundStyle(theme.accentColor) .foregroundStyle(theme.accentColor)
.minimumScaleFactor(0.7)
.lineLimit(1)
Text(stat.label) Text(stat.label.uppercased())
.font(.system(size: 20)) .font(.system(size: 13, weight: .bold))
.tracking(2.2)
.foregroundStyle(theme.secondaryTextColor) .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)
)
)
} }
} }
.padding(.vertical, 30)
.padding(.horizontal, 40)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(theme.textColor.opacity(0.05))
)
} }
} }
@@ -114,42 +165,54 @@ struct ShareProgressRing: View {
let current: Int let current: Int
let total: Int let total: Int
let theme: ShareTheme let theme: ShareTheme
var size: CGFloat = 320 var size: CGFloat = 340
var lineWidth: CGFloat = 24 var lineWidth: CGFloat = 30
private let segmentCount = 72
private var progress: Double { private var progress: Double {
guard total > 0 else { return 0 } guard total > 0 else { return 0 }
return Double(current) / Double(total) return min(max(Double(current) / Double(total), 0), 1)
}
private var filledSegments: Int {
Int(round(progress * Double(segmentCount)))
} }
var body: some View { var body: some View {
ZStack { ZStack {
// Background ring ForEach(0..<segmentCount, id: \.self) { index in
Circle() Capsule()
.stroke(theme.accentColor.opacity(0.2), lineWidth: lineWidth) .fill(index < filledSegments ? theme.accentColor : theme.surfaceColor.opacity(0.90))
.frame(width: size, height: size) .frame(width: lineWidth * 0.62, height: lineWidth * 1.18)
.offset(y: -(size / 2 - lineWidth * 0.78))
.rotationEffect(.degrees(Double(index) * 360 / Double(segmentCount)))
}
// Progress ring ForEach([0.25, 0.50, 0.75], id: \.self) { mark in
Circle() Circle()
.trim(from: 0, to: progress) .fill(theme.textColor.opacity(0.32))
.stroke( .frame(width: 8, height: 8)
theme.accentColor, .offset(y: -(size / 2 - lineWidth * 0.20))
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) .rotationEffect(.degrees(mark * 360))
) }
.frame(width: size, height: size)
.rotationEffect(.degrees(-90))
// Center content Circle()
VStack(spacing: 8) { .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)") Text("\(current)")
.font(.system(size: 96, weight: .bold, design: .rounded)) .font(.system(size: 108, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor) .foregroundStyle(theme.textColor)
Text("of \(total)") Text("of \(total)")
.font(.system(size: 32, weight: .medium)) .font(.system(size: 27, weight: .light))
.foregroundStyle(theme.secondaryTextColor) .foregroundStyle(theme.secondaryTextColor)
} }
} }
.frame(width: size, height: size)
} }
} }
@@ -158,7 +221,6 @@ struct ShareProgressRing: View {
@MainActor @MainActor
final class ShareMapSnapshotGenerator { final class ShareMapSnapshotGenerator {
/// Generate a progress map showing visited/remaining stadiums
func generateProgressMap( func generateProgressMap(
visited: [Stadium], visited: [Stadium],
remaining: [Stadium], remaining: [Stadium],
@@ -167,9 +229,8 @@ final class ShareMapSnapshotGenerator {
let allStadiums = visited + remaining let allStadiums = visited + remaining
guard !allStadiums.isEmpty else { return nil } guard !allStadiums.isEmpty else { return nil }
let region = calculateRegion(for: allStadiums)
let options = MKMapSnapshotter.Options() let options = MKMapSnapshotter.Options()
options.region = region options.region = calculateRegion(for: allStadiums)
options.size = ShareCardDimensions.mapSnapshotSize options.size = ShareCardDimensions.mapSnapshotSize
options.mapType = theme.useDarkMap ? .mutedStandard : .standard options.mapType = theme.useDarkMap ? .mutedStandard : .standard
@@ -188,19 +249,15 @@ final class ShareMapSnapshotGenerator {
} }
} }
/// Generate a route map for trip cards
func generateRouteMap( func generateRouteMap(
stops: [TripStop], stops: [TripStop],
theme: ShareTheme theme: ShareTheme
) async -> UIImage? { ) async -> UIImage? {
let stopsWithCoordinates = stops.filter { $0.coordinate != nil } let validStops = stops.filter { $0.coordinate != nil }
guard stopsWithCoordinates.count >= 2 else { return nil } guard validStops.count >= 2 else { return nil }
let coordinates = stopsWithCoordinates.compactMap { $0.coordinate }
let region = calculateRegion(for: coordinates)
let options = MKMapSnapshotter.Options() let options = MKMapSnapshotter.Options()
options.region = region options.region = calculateRegion(for: validStops.compactMap { $0.coordinate })
options.size = ShareCardDimensions.routeMapSize options.size = ShareCardDimensions.routeMapSize
options.mapType = theme.useDarkMap ? .mutedStandard : .standard options.mapType = theme.useDarkMap ? .mutedStandard : .standard
@@ -208,18 +265,12 @@ final class ShareMapSnapshotGenerator {
do { do {
let snapshot = try await snapshotter.start() let snapshot = try await snapshotter.start()
return drawRoute( return drawRoute(on: snapshot, stops: validStops, accentColor: UIColor(theme.accentColor))
on: snapshot,
stops: stopsWithCoordinates,
accentColor: UIColor(theme.accentColor)
)
} catch { } catch {
return nil return nil
} }
} }
// MARK: - Private Helpers
private func calculateRegion(for stadiums: [Stadium]) -> MKCoordinateRegion { private func calculateRegion(for stadiums: [Stadium]) -> MKCoordinateRegion {
let coordinates = stadiums.map { let coordinates = stadiums.map {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
@@ -233,17 +284,16 @@ final class ShareMapSnapshotGenerator {
let minLon = coordinates.map(\.longitude).min() ?? 0 let minLon = coordinates.map(\.longitude).min() ?? 0
let maxLon = coordinates.map(\.longitude).max() ?? 0 let maxLon = coordinates.map(\.longitude).max() ?? 0
let center = CLLocationCoordinate2D( return MKCoordinateRegion(
latitude: (minLat + maxLat) / 2, center: CLLocationCoordinate2D(
longitude: (minLon + maxLon) / 2 latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
),
span: MKCoordinateSpan(
latitudeDelta: max((maxLat - minLat) * 1.35, 1),
longitudeDelta: max((maxLon - minLon) * 1.35, 1)
)
) )
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( private func drawStadiumMarkers(
@@ -252,26 +302,25 @@ final class ShareMapSnapshotGenerator {
remaining: [Stadium], remaining: [Stadium],
accentColor: UIColor accentColor: UIColor
) -> UIImage { ) -> UIImage {
let size = ShareCardDimensions.mapSnapshotSize UIGraphicsImageRenderer(size: ShareCardDimensions.mapSnapshotSize).image { context in
return UIGraphicsImageRenderer(size: size).image { context in
snapshot.image.draw(at: .zero) snapshot.image.draw(at: .zero)
// Draw remaining (gray) first
for stadium in remaining { for stadium in remaining {
let point = snapshot.point(for: CLLocationCoordinate2D( drawStadiumDot(
latitude: stadium.latitude, at: snapshot.point(for: CLLocationCoordinate2D(latitude: stadium.latitude, longitude: stadium.longitude)),
longitude: stadium.longitude color: UIColor.systemGray3,
)) visited: false,
drawMarker(at: point, color: .gray, context: context.cgContext) context: context.cgContext
)
} }
// Draw visited (accent) on top
for stadium in visited { for stadium in visited {
let point = snapshot.point(for: CLLocationCoordinate2D( drawStadiumDot(
latitude: stadium.latitude, at: snapshot.point(for: CLLocationCoordinate2D(latitude: stadium.latitude, longitude: stadium.longitude)),
longitude: stadium.longitude color: accentColor,
)) visited: true,
drawMarker(at: point, color: accentColor, context: context.cgContext) context: context.cgContext
)
} }
} }
} }
@@ -281,126 +330,125 @@ final class ShareMapSnapshotGenerator {
stops: [TripStop], stops: [TripStop],
accentColor: UIColor accentColor: UIColor
) -> UIImage { ) -> UIImage {
let size = ShareCardDimensions.routeMapSize UIGraphicsImageRenderer(size: ShareCardDimensions.routeMapSize).image { context in
return UIGraphicsImageRenderer(size: size).image { context in
snapshot.image.draw(at: .zero) snapshot.image.draw(at: .zero)
let cgContext = context.cgContext let cg = 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 let points = stops.compactMap { stop -> CGPoint? in
guard let coord = stop.coordinate else { return nil } guard let coord = stop.coordinate else { return nil }
return snapshot.point(for: coord) return snapshot.point(for: coord)
} }
if let first = points.first { if let first = points.first {
cgContext.move(to: 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() { for point in points.dropFirst() {
cgContext.addLine(to: point) cg.addLine(to: point)
} }
cgContext.strokePath() cg.strokePath()
cg.setStrokeColor(accentColor.cgColor)
cg.setLineWidth(6)
cg.move(to: first)
for point in points.dropFirst() {
cg.addLine(to: point)
}
cg.strokePath()
} }
// Draw city markers
for (index, stop) in stops.enumerated() { for (index, stop) in stops.enumerated() {
guard let coord = stop.coordinate else { continue } guard let coord = stop.coordinate else { continue }
let point = snapshot.point(for: coord) let point = snapshot.point(for: coord)
drawCityMarker( let isFirst = index == 0
let isLast = index == stops.count - 1
drawCityLabel(
at: point, at: point,
label: String(stop.city.prefix(3)).uppercased(), label: isFirst ? "START" : isLast ? "FINISH" : String(stop.city.prefix(3)).uppercased(),
isFirst: index == 0, endpoint: isFirst || isLast,
isLast: index == stops.count - 1,
color: accentColor, color: accentColor,
context: cgContext context: cg
) )
} }
} }
} }
private func drawMarker(at point: CGPoint, color: UIColor, context: CGContext) { private func drawStadiumDot(
let markerSize: CGFloat = 16 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.setFillColor(color.cgColor)
context.fillEllipse(in: CGRect( context.fillEllipse(in: CGRect(x: point.x - size / 2, y: point.y - size / 2, width: size, height: size))
x: point.x - markerSize / 2,
y: point.y - markerSize / 2,
width: markerSize,
height: markerSize
))
context.setStrokeColor(UIColor.white.cgColor) if visited {
context.setLineWidth(2) context.setStrokeColor(UIColor.white.cgColor)
context.strokeEllipse(in: CGRect( context.setLineWidth(2.6)
x: point.x - markerSize / 2, context.setLineCap(.round)
y: point.y - markerSize / 2, context.setLineJoin(.round)
width: markerSize,
height: markerSize 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 drawCityMarker( private func drawCityLabel(
at point: CGPoint, at point: CGPoint,
label: String, label: String,
isFirst: Bool, endpoint: Bool,
isLast: Bool,
color: UIColor, color: UIColor,
context: CGContext context: CGContext
) { ) {
let markerSize: CGFloat = isFirst || isLast ? 24 : 18 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))
// Outer circle
context.setFillColor(color.cgColor) context.setFillColor(color.cgColor)
context.fillEllipse(in: CGRect( context.fillEllipse(in: CGRect(x: point.x - dotSize / 2, y: point.y - dotSize / 2, width: dotSize, height: dotSize))
x: point.x - markerSize / 2,
y: point.y - markerSize / 2,
width: markerSize,
height: markerSize
))
// White border let font = UIFont.systemFont(ofSize: endpoint ? 12.5 : 11, weight: .heavy)
context.setStrokeColor(UIColor.white.cgColor) let attrs: [NSAttributedString.Key: Any] = [
context.setLineWidth(3) .font: font,
context.strokeEllipse(in: CGRect( .foregroundColor: UIColor.white
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 textSize = (label as NSString).size(withAttributes: attrs)
let labelBgRect = CGRect( let bgRect = CGRect(
x: point.x - 22, x: point.x - textSize.width / 2 - 11,
y: point.y - markerSize / 2 - 24, y: point.y - dotSize / 2 - textSize.height - 12,
width: 44, width: textSize.width + 22,
height: 18 height: textSize.height + 8
) )
context.setFillColor(color.withAlphaComponent(0.9).cgColor)
let path = UIBezierPath(roundedRect: labelBgRect, cornerRadius: 4) let path = UIBezierPath(roundedRect: bgRect, cornerRadius: 9)
context.setFillColor(color.withAlphaComponent(0.94).cgColor)
context.addPath(path.cgPath) context.addPath(path.cgPath)
context.fillPath() context.fillPath()
label.draw(in: labelRect, withAttributes: attributes) (label as NSString).draw(
in: CGRect(
x: bgRect.origin.x + 11,
y: bgRect.origin.y + 4,
width: textSize.width,
height: textSize.height
),
withAttributes: attrs
)
} }
} }

View File

@@ -2,7 +2,8 @@
// ShareCardSportBackground.swift // ShareCardSportBackground.swift
// SportsTime // SportsTime
// //
// Sport-specific background with floating league icons for share cards. // New visual language for share cards: atmospheric gradients, sport-specific linework,
// and strong edge shading for depth.
// //
import SwiftUI import SwiftUI
@@ -11,70 +12,202 @@ struct ShareCardSportBackground: View {
let sports: Set<Sport> let sports: Set<Sport>
let theme: ShareTheme let theme: ShareTheme
/// Fixed positions for 12 scattered icons (x, y as percentage, rotation, scale) private var primarySport: Sport? {
private let iconConfigs: [(x: CGFloat, y: CGFloat, rotation: Double, scale: CGFloat)] = [ sports.sorted { $0.rawValue < $1.rawValue }.first
(0.08, 0.08, -20, 0.9),
(0.92, 0.05, 15, 0.85),
(0.15, 0.28, 25, 0.8),
(0.88, 0.22, -10, 0.95),
(0.05, 0.48, 30, 0.85),
(0.95, 0.45, -25, 0.9),
(0.12, 0.68, -15, 0.8),
(0.90, 0.65, 20, 0.85),
(0.08, 0.88, 10, 0.9),
(0.92, 0.85, -30, 0.8),
(0.50, 0.15, 5, 0.75),
(0.50, 0.90, -5, 0.75)
]
/// Get icon name for a given index, cycling through sports
private func iconName(at index: Int) -> String {
let sportArray = Array(sports).sorted { $0.rawValue < $1.rawValue }
guard !sportArray.isEmpty else {
return "sportscourt.fill"
}
return sportArray[index % sportArray.count].iconName
} }
var body: some View { var body: some View {
ZStack { ZStack {
// Base gradient baseLayer
glowLayer
patternLayer
edgeShadeLayer
}
}
private var baseLayer: some View {
LinearGradient(
colors: [
theme.gradientColors.first ?? .black,
theme.midGradientColor,
theme.gradientColors.last ?? .black
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.overlay {
LinearGradient( LinearGradient(
colors: theme.gradientColors, colors: [
.black.opacity(0.20),
.clear,
.black.opacity(0.30)
],
startPoint: .top, startPoint: .top,
endPoint: .bottom endPoint: .bottom
) )
}
}
// Scattered sport icons private var glowLayer: some View {
GeometryReader { geo in GeometryReader { geo in
ForEach(0..<iconConfigs.count, id: \.self) { index in ZStack {
let config = iconConfigs[index] Circle()
Image(systemName: iconName(at: index)) .fill(theme.accentColor.opacity(0.24))
.font(.system(size: 32 * config.scale)) .frame(width: geo.size.width * 0.95)
.foregroundStyle(theme.accentColor.opacity(0.15)) .offset(x: -geo.size.width * 0.30, y: -geo.size.height * 0.35)
.rotationEffect(.degrees(config.rotation))
.position( Circle()
x: geo.size.width * config.x, .fill(theme.textColor.opacity(0.10))
y: geo.size.height * config.y .frame(width: geo.size.width * 0.72)
) .offset(x: geo.size.width * 0.35, y: -geo.size.height * 0.10)
}
Ellipse()
.fill(theme.accentColor.opacity(0.16))
.frame(width: geo.size.width * 1.20, height: geo.size.height * 0.45)
.offset(y: geo.size.height * 0.42)
} }
.blur(radius: 58)
}
}
@ViewBuilder
private var patternLayer: some View {
switch primarySport {
case .mlb?:
BaseballStitchPattern()
.stroke(theme.textColor.opacity(0.11), lineWidth: 1.8)
case .nba?, .wnba?:
CourtArcPattern()
.stroke(theme.textColor.opacity(0.10), lineWidth: 1.6)
case .nhl?:
IceShardPattern()
.stroke(theme.textColor.opacity(0.10), lineWidth: 1.4)
case .nfl?, .mls?, .nwsl?, nil:
PitchBandPattern()
.fill(theme.textColor.opacity(0.09))
}
}
private var edgeShadeLayer: some View {
ZStack {
RadialGradient(
colors: [.clear, .black.opacity(0.45)],
center: .center,
startRadius: 120,
endRadius: 980
)
LinearGradient(
colors: [.black.opacity(0.42), .clear, .black.opacity(0.42)],
startPoint: .top,
endPoint: .bottom
)
} }
} }
} }
#Preview("Single Sport - MLB") { private struct BaseballStitchPattern: Shape {
ShareCardSportBackground( func path(in rect: CGRect) -> Path {
sports: [.mlb], var path = Path()
theme: .sunset let rowStep: CGFloat = 150
) let colStep: CGFloat = 220
.frame(width: 400, height: 600)
var y: CGFloat = -80
while y < rect.maxY + 120 {
var x: CGFloat = -60
while x < rect.maxX + 180 {
let start = CGPoint(x: x, y: y)
let mid = CGPoint(x: x + 85, y: y + 26)
let end = CGPoint(x: x + 170, y: y + 62)
path.move(to: start)
path.addQuadCurve(to: mid, control: CGPoint(x: x + 48, y: y - 22))
path.addQuadCurve(to: end, control: CGPoint(x: x + 122, y: y + 70))
x += colStep
}
y += rowStep
}
return path
}
} }
#Preview("Multiple Sports") { private struct CourtArcPattern: Shape {
ShareCardSportBackground( func path(in rect: CGRect) -> Path {
sports: [.mlb, .nba, .nfl], var path = Path()
theme: .dark let spacing: CGFloat = 170
)
.frame(width: 400, height: 600) var y: CGFloat = -80
while y < rect.maxY + 120 {
var x: CGFloat = -80
while x < rect.maxX + 120 {
let circleRect = CGRect(x: x, y: y, width: 132, height: 132)
path.addEllipse(in: circleRect)
path.move(to: CGPoint(x: x - 20, y: y + 66))
path.addLine(to: CGPoint(x: x + 152, y: y + 66))
x += spacing
}
y += spacing
}
return path
}
}
private struct IceShardPattern: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let spacing: CGFloat = 92
var baseX: CGFloat = -120
while baseX < rect.maxX + 200 {
path.move(to: CGPoint(x: baseX, y: -80))
path.addLine(to: CGPoint(x: baseX + 44, y: rect.maxY * 0.26))
path.addLine(to: CGPoint(x: baseX - 26, y: rect.maxY * 0.52))
path.addLine(to: CGPoint(x: baseX + 30, y: rect.maxY + 100))
baseX += spacing
}
var baseY: CGFloat = -60
while baseY < rect.maxY + 160 {
path.move(to: CGPoint(x: -80, y: baseY))
path.addLine(to: CGPoint(x: rect.maxX + 80, y: baseY + 50))
baseY += spacing
}
return path
}
}
private struct PitchBandPattern: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let stripeWidth: CGFloat = 54
let stripeGap: CGFloat = 34
let diagonalLength = rect.width + rect.height + 240
var x: CGFloat = -rect.height - 120
while x < rect.width + rect.height + 120 {
path.addRect(
CGRect(
x: x,
y: -140,
width: stripeWidth,
height: diagonalLength
)
)
x += stripeWidth + stripeGap
}
return path.applying(CGAffineTransform(rotationAngle: .pi / 3.8))
}
}
#Preview("Share Background") {
ShareCardSportBackground(sports: [.nfl], theme: .midnight)
.frame(width: 420, height: 720)
} }

View File

@@ -101,7 +101,7 @@ enum ShareThemePreferences {
switch cardType { switch cardType {
case .tripSummary: case .tripSummary:
return tripTheme return tripTheme
case .achievementSpotlight, .achievementCollection, .achievementMilestone, .achievementContext: case .achievementSpotlight, .achievementCollection:
return achievementTheme return achievementTheme
case .stadiumProgress: case .stadiumProgress:
return progressTheme return progressTheme
@@ -112,7 +112,7 @@ enum ShareThemePreferences {
switch cardType { switch cardType {
case .tripSummary: case .tripSummary:
tripTheme = theme tripTheme = theme
case .achievementSpotlight, .achievementCollection, .achievementMilestone, .achievementContext: case .achievementSpotlight, .achievementCollection:
achievementTheme = theme achievementTheme = theme
case .stadiumProgress: case .stadiumProgress:
progressTheme = theme progressTheme = theme

View File

@@ -21,8 +21,6 @@ enum ShareCardType: String, CaseIterable {
case tripSummary case tripSummary
case achievementSpotlight case achievementSpotlight
case achievementCollection case achievementCollection
case achievementMilestone
case achievementContext
case stadiumProgress case stadiumProgress
} }
@@ -124,6 +122,72 @@ struct ShareTheme: Identifiable, Hashable {
static func theme(byId id: String) -> ShareTheme { static func theme(byId id: String) -> ShareTheme {
all.first { $0.id == id } ?? .dark all.first { $0.id == id } ?? .dark
} }
// MARK: - Derived Theme Properties
/// Glass panel fill textColor at low opacity
var surfaceColor: Color {
textColor.opacity(0.08)
}
/// Panel border textColor at medium-low opacity
var borderColor: Color {
textColor.opacity(0.15)
}
/// Glow effect color accentColor at medium opacity
var glowColor: Color {
accentColor.opacity(0.4)
}
/// Highlight gradient for accent elements
var highlightGradient: [Color] {
[accentColor, accentColor.opacity(0.6)]
}
/// Mid-tone color derived from gradient endpoints for richer backgrounds
var midGradientColor: Color {
gradientColors.count >= 2
? gradientColors[0].blendedWith(gradientColors[1], fraction: 0.5)
: gradientColors.first ?? .black
}
}
// MARK: - Color Blending Helper
extension Color {
/// Simple blend between two colors at a given fraction (0 = self, 1 = other)
func blendedWith(_ other: Color, fraction: Double) -> Color {
let f = max(0, min(1, fraction))
let c1 = UIColor(self).rgbaComponents
let c2 = UIColor(other).rgbaComponents
return Color(
red: c1.r + (c2.r - c1.r) * f,
green: c1.g + (c2.g - c1.g) * f,
blue: c1.b + (c2.b - c1.b) * f,
opacity: c1.a + (c2.a - c1.a) * f
)
}
}
private extension UIColor {
var rgbaComponents: (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) {
var r: CGFloat = 0
var g: CGFloat = 0
var b: CGFloat = 0
var a: CGFloat = 0
if getRed(&r, green: &g, blue: &b, alpha: &a) {
return (r, g, b, a)
}
var white: CGFloat = 0
if getWhite(&white, alpha: &a) {
return (white, white, white, a)
}
return (0, 0, 0, 1)
}
} }
// MARK: - Share Errors // MARK: - Share Errors

View File

@@ -2,7 +2,8 @@
// TripCardGenerator.swift // TripCardGenerator.swift
// SportsTime // 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 import SwiftUI
@@ -23,19 +24,12 @@ struct TripShareContent: ShareableContent {
theme: theme theme: theme
) )
let cardView = TripCardView( let renderer = ImageRenderer(content: TripCardView(trip: trip, theme: theme, mapSnapshot: mapSnapshot))
trip: trip,
theme: theme,
mapSnapshot: mapSnapshot
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0 renderer.scale = 3.0
guard let image = renderer.uiImage else { guard let image = renderer.uiImage else {
throw ShareError.renderingFailed throw ShareError.renderingFailed
} }
return image return image
} }
} }
@@ -47,80 +41,169 @@ private struct TripCardView: View {
let theme: ShareTheme let theme: ShareTheme
let mapSnapshot: UIImage? let mapSnapshot: UIImage?
private var sportTitle: String { private var sortedSports: [Sport] {
if trip.uniqueSports.count == 1, let sport = trip.uniqueSports.first { trip.uniqueSports.sorted { $0.rawValue < $1.rawValue }
return "My \(sport.displayName) Road Trip"
}
return "My Sports Road Trip"
} }
private var primarySport: Sport? { /// Map each unique city to its stadium name(s) from the stops.
trip.uniqueSports.first 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 { var body: some View {
ZStack { ZStack {
ShareCardBackground(theme: theme, sports: trip.uniqueSports) (theme.gradientColors.first ?? .black)
.ignoresSafeArea()
VStack(spacing: 40) { VStack(spacing: 0) {
ShareCardHeader( // Header
title: sportTitle, Text(sortedSports.map { $0.displayName.uppercased() }.joined(separator: " + "))
sport: primarySport, .font(.system(size: 20, weight: .black))
theme: theme .tracking(8)
) .foregroundStyle(theme.secondaryTextColor)
.padding(.top, 20)
// Map Text("ROAD TRIP")
if let snapshot = mapSnapshot { .font(.system(size: 52, weight: .black))
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))
.foregroundStyle(theme.textColor) .foregroundStyle(theme.textColor)
.padding(.top, 8)
// Stats row Text(trip.formattedDateRange.uppercased())
ShareStatsRow( .font(.system(size: 18, weight: .bold))
stats: [ .tracking(2)
(value: String(format: "%.0f", trip.totalDistanceMiles), label: "miles"), .foregroundStyle(theme.secondaryTextColor)
(value: "\(trip.totalGames)", label: "games"), .padding(.top, 8)
(value: "\(trip.cities.count)", label: "cities")
],
theme: theme
)
// City trail
cityTrail
Spacer() 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) .padding(ShareCardDimensions.padding)
} }
.frame( .frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
} }
private var cityTrail: some View { private func statItem(value: String, label: String) -> some View {
let cities = trip.cities VStack(spacing: 6) {
let displayText = cities.joined(separator: "") Text(value)
.font(.system(size: 52, weight: .black, design: .rounded))
return Text(displayText) .foregroundStyle(theme.accentColor)
.font(.system(size: 24, weight: .medium)) .minimumScaleFactor(0.6)
.foregroundStyle(theme.secondaryTextColor) Text(label)
.multilineTextAlignment(.center) .font(.system(size: 16, weight: .bold))
.lineLimit(3) .tracking(2)
.padding(.horizontal, 40) .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
} }
} }

View 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

View File

@@ -83,7 +83,7 @@ struct AchievementsListView: View {
let earned = displayAchievements.filter { $0.isEarned }.count let earned = displayAchievements.filter { $0.isEarned }.count
let total = displayAchievements.count let total = displayAchievements.count
let progress = total > 0 ? Double(earned) / Double(total) : 0 let progress = total > 0 ? Double(earned) / Double(total) : 0
let completedGold = Color(hex: "FFD700") let completedGold = colorScheme == .dark ? Color(hex: "FFD700") : Color(hex: "B8860B")
let filterTitle = selectedSport?.displayName ?? "All Sports" let filterTitle = selectedSport?.displayName ?? "All Sports"
let accentColor = selectedSport?.themeColor ?? Theme.warmOrange let accentColor = selectedSport?.themeColor ?? Theme.warmOrange
@@ -297,8 +297,10 @@ struct AchievementCard: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
// Gold color for completed achievements // Gold that's readable in both light and dark mode
private let completedGold = Color(hex: "FFD700") private var completedGold: Color {
colorScheme == .dark ? Color(hex: "FFD700") : Color(hex: "B8860B")
}
var body: some View { var body: some View {
VStack(spacing: Theme.Spacing.sm) { VStack(spacing: Theme.Spacing.sm) {
@@ -460,8 +462,10 @@ struct AchievementDetailSheet: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
// Gold color for completed achievements // Gold that's readable in both light and dark mode
private let completedGold = Color(hex: "FFD700") private var completedGold: Color {
colorScheme == .dark ? Color(hex: "FFD700") : Color(hex: "B8860B")
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {

View File

@@ -34,8 +34,8 @@ final class DebugShareExporter {
exportedCount = 0 exportedCount = 0
let achievementCount = AchievementRegistry.all.count let achievementCount = AchievementRegistry.all.count
// spotlight + milestone + context = 3 * achievements, collection ~5, progress 12, trips 4, icons 1 // spotlight = 1 * achievements, collection ~5, progress 12, trips 4, icons 1
totalCount = (achievementCount * 3) + 5 + 12 + 4 + 1 totalCount = achievementCount + 5 + 12 + 4 + 1
do { do {
// Step 1: Create export directory // Step 1: Create export directory
@@ -51,13 +51,10 @@ final class DebugShareExporter {
let engine = AchievementEngine(modelContext: modelContext) let engine = AchievementEngine(modelContext: modelContext)
_ = try await engine.recalculateAllAchievements() _ = try await engine.recalculateAllAchievements()
// Step 4: Export achievement spotlight + milestone cards // Step 4: Export achievement spotlight cards
currentStep = "Exporting spotlight cards..." currentStep = "Exporting spotlight cards..."
let spotlightTheme = ShareThemePreferences.theme(for: .achievementSpotlight) let spotlightTheme = ShareThemePreferences.theme(for: .achievementSpotlight)
let milestoneTheme = ShareThemePreferences.theme(for: .achievementMilestone)
let spotlightDir = exportDir.appendingPathComponent("achievements/spotlight") let spotlightDir = exportDir.appendingPathComponent("achievements/spotlight")
let milestoneDir = exportDir.appendingPathComponent("achievements/milestone")
for definition in AchievementRegistry.all { for definition in AchievementRegistry.all {
let achievement = AchievementProgress( let achievement = AchievementProgress(
@@ -68,21 +65,12 @@ final class DebugShareExporter {
earnedAt: Date() earnedAt: Date()
) )
// Spotlight
currentStep = "Spotlight: \(definition.name)" currentStep = "Spotlight: \(definition.name)"
let spotlightContent = AchievementSpotlightContent(achievement: achievement) let spotlightContent = AchievementSpotlightContent(achievement: achievement)
let spotlightImage = try await spotlightContent.render(theme: spotlightTheme) let spotlightImage = try await spotlightContent.render(theme: spotlightTheme)
try savePNG(spotlightImage, to: spotlightDir.appendingPathComponent("\(definition.id).png")) try savePNG(spotlightImage, to: spotlightDir.appendingPathComponent("\(definition.id).png"))
exportedCount += 1 exportedCount += 1
updateProgress() updateProgress()
// Milestone
currentStep = "Milestone: \(definition.name)"
let milestoneContent = AchievementMilestoneContent(achievement: achievement)
let milestoneImage = try await milestoneContent.render(theme: milestoneTheme)
try savePNG(milestoneImage, to: milestoneDir.appendingPathComponent("\(definition.id).png"))
exportedCount += 1
updateProgress()
} }
// Step 5: Export achievement collection cards // Step 5: Export achievement collection cards
@@ -139,36 +127,7 @@ final class DebugShareExporter {
exportedCount += 1 exportedCount += 1
updateProgress() updateProgress()
// Step 6: Export achievement context cards // Step 6: Export progress cards
currentStep = "Generating map snapshot for context cards..."
let contextTheme = ShareThemePreferences.theme(for: .achievementContext)
let contextDir = exportDir.appendingPathComponent("achievements/context")
let contextStops = Self.eastCoastStops()
let mapGenerator = ShareMapSnapshotGenerator()
let mapSnapshot = await mapGenerator.generateRouteMap(stops: contextStops, theme: contextTheme)
for definition in AchievementRegistry.all {
currentStep = "Context: \(definition.name)"
let achievement = AchievementProgress(
definition: definition,
currentProgress: totalRequired(for: definition),
totalRequired: totalRequired(for: definition),
hasStoredAchievement: true,
earnedAt: Date()
)
let contextContent = AchievementContextContent(
achievement: achievement,
tripName: "Road Trip 2026",
mapSnapshot: mapSnapshot
)
let image = try await contextContent.render(theme: contextTheme)
try savePNG(image, to: contextDir.appendingPathComponent("\(definition.id).png"))
exportedCount += 1
updateProgress()
}
// Step 7: Export progress cards
currentStep = "Exporting progress cards..." currentStep = "Exporting progress cards..."
let progressTheme = ShareThemePreferences.theme(for: .stadiumProgress) let progressTheme = ShareThemePreferences.theme(for: .stadiumProgress)
let progressDir = exportDir.appendingPathComponent("progress") let progressDir = exportDir.appendingPathComponent("progress")
@@ -204,7 +163,7 @@ final class DebugShareExporter {
} }
} }
// Step 8: Export trip cards // Step 7: Export trip cards
currentStep = "Exporting trip cards..." currentStep = "Exporting trip cards..."
let tripTheme = ShareThemePreferences.theme(for: .tripSummary) let tripTheme = ShareThemePreferences.theme(for: .tripSummary)
let tripDir = exportDir.appendingPathComponent("trips") let tripDir = exportDir.appendingPathComponent("trips")
@@ -220,7 +179,7 @@ final class DebugShareExporter {
updateProgress() updateProgress()
} }
// Step 9: Export sports icon // Step 8: Export sports icon
currentStep = "Exporting sports icon..." currentStep = "Exporting sports icon..."
let iconDir = exportDir.appendingPathComponent("icons") let iconDir = exportDir.appendingPathComponent("icons")
if let iconData = SportsIconImageGenerator.generatePNGData(), if let iconData = SportsIconImageGenerator.generatePNGData(),
@@ -244,6 +203,184 @@ final class DebugShareExporter {
isExporting = false isExporting = false
} }
// MARK: - Export Achievement Samples
func exportAchievementSamples() async {
guard !isExporting else { return }
isExporting = true
error = nil
exportPath = nil
exportedCount = 0
do {
currentStep = "Creating export directory..."
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd_HHmmss"
let timestamp = formatter.string(from: Date())
let exportDir = docs.appendingPathComponent("DebugExport/samples_\(timestamp)")
try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true)
// Pick a few representative achievements across sports
let defs = AchievementRegistry.all
let sampleDefs = [
defs.first { $0.sport == .mlb } ?? defs[0],
defs.first { $0.sport == .nba } ?? defs[1],
defs.first { $0.sport == .nhl } ?? defs[2],
defs.first { $0.name.lowercased().contains("complete") } ?? defs[3],
defs.first { $0.category == .journey } ?? defs[min(4, defs.count - 1)]
]
totalCount = sampleDefs.count
let spotlightTheme = ShareThemePreferences.theme(for: .achievementSpotlight)
for def in sampleDefs {
let achievement = AchievementProgress(
definition: def,
currentProgress: totalRequired(for: def),
totalRequired: totalRequired(for: def),
hasStoredAchievement: true,
earnedAt: Date()
)
let safeName = def.id.replacingOccurrences(of: " ", with: "_")
currentStep = "Spotlight: \(def.name)"
let spotlightContent = AchievementSpotlightContent(achievement: achievement)
let spotlightImage = try await spotlightContent.render(theme: spotlightTheme)
try savePNG(spotlightImage, to: exportDir.appendingPathComponent("spotlight_\(safeName).png"))
exportedCount += 1
updateProgress()
}
exportPath = exportDir.path
currentStep = "Export complete!"
print("DEBUG SAMPLES: \(exportDir.path)")
} catch {
self.error = error.localizedDescription
currentStep = "Export failed"
print("DEBUG SAMPLE ERROR: \(error)")
}
isExporting = false
}
// MARK: - Export Progress Samples
func exportProgressSamples() async {
guard !isExporting else { return }
isExporting = true
error = nil
exportPath = nil
exportedCount = 0
do {
currentStep = "Creating export directory..."
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd_HHmmss"
let timestamp = formatter.string(from: Date())
let exportDir = docs.appendingPathComponent("DebugExport/progress_samples_\(timestamp)")
try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true)
let allStadiums = AppDataProvider.shared.stadiums
let theme = ShareThemePreferences.theme(for: .stadiumProgress)
// Render real progress cards at different percentages per sport
let sports: [Sport] = [.mlb, .nba, .nhl]
let percentages = [25, 50, 75, 100]
totalCount = sports.count * percentages.count
for sport in sports {
let sportStadiums = allStadiums.filter { $0.sport == sport }
let total = sportStadiums.count
for pct in percentages {
let visitedCount = (total * pct) / 100
let visited = Array(sportStadiums.prefix(visitedCount))
let remaining = Array(sportStadiums.dropFirst(visitedCount))
let leagueProgress = LeagueProgress(
sport: sport,
totalStadiums: total,
visitedStadiums: visitedCount,
stadiumsVisited: visited,
stadiumsRemaining: remaining
)
let tripCount = pct == 100 ? 5 : pct / 25
currentStep = "Progress: \(sport.rawValue) \(pct)%"
let content = ProgressShareContent(
progress: leagueProgress,
tripCount: tripCount
)
let image = try await content.render(theme: theme)
try savePNG(image, to: exportDir.appendingPathComponent("\(sport.rawValue)_\(pct).png"))
exportedCount += 1
updateProgress()
}
}
exportPath = exportDir.path
currentStep = "Export complete!"
print("DEBUG PROGRESS SAMPLES: \(exportDir.path)")
} catch {
self.error = error.localizedDescription
currentStep = "Export failed"
print("DEBUG PROGRESS SAMPLE ERROR: \(error)")
}
isExporting = false
}
// MARK: - Export Trip Samples
func exportTripSamples() async {
guard !isExporting else { return }
isExporting = true
error = nil
exportPath = nil
exportedCount = 0
do {
currentStep = "Creating export directory..."
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd_HHmmss"
let timestamp = formatter.string(from: Date())
let exportDir = docs.appendingPathComponent("DebugExport/trip_samples_\(timestamp)")
try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true)
let theme = ShareThemePreferences.theme(for: .tripSummary)
let dummyTrips = Self.buildDummyTrips()
totalCount = dummyTrips.count
for trip in dummyTrips {
currentStep = "Trip: \(trip.name)"
let content = TripShareContent(trip: trip)
let image = try await content.render(theme: theme)
let safeName = trip.name.replacingOccurrences(of: " ", with: "_")
try savePNG(image, to: exportDir.appendingPathComponent("\(safeName).png"))
exportedCount += 1
updateProgress()
}
exportPath = exportDir.path
currentStep = "Export complete!"
print("DEBUG TRIP SAMPLES: \(exportDir.path)")
} catch {
self.error = error.localizedDescription
currentStep = "Export failed"
print("DEBUG TRIP SAMPLE ERROR: \(error)")
}
isExporting = false
}
// MARK: - Add All Stadium Visits // MARK: - Add All Stadium Visits
func addAllStadiumVisits(modelContext: ModelContext) async { func addAllStadiumVisits(modelContext: ModelContext) async {
@@ -311,9 +448,7 @@ final class DebugShareExporter {
let subdirs = [ let subdirs = [
"achievements/spotlight", "achievements/spotlight",
"achievements/milestone",
"achievements/collection", "achievements/collection",
"achievements/context",
"progress", "progress",
"trips", "trips",
"icons" "icons"

View File

@@ -360,6 +360,33 @@ struct SettingsView: View {
Label("Export All Shareables", systemImage: "square.and.arrow.up.on.square") Label("Export All Shareables", systemImage: "square.and.arrow.up.on.square")
} }
Button {
showExportProgress = true
Task {
await exporter.exportAchievementSamples()
}
} label: {
Label("Export Achievement Samples", systemImage: "paintbrush")
}
Button {
showExportProgress = true
Task {
await exporter.exportProgressSamples()
}
} label: {
Label("Export Progress Samples", systemImage: "chart.bar.fill")
}
Button {
showExportProgress = true
Task {
await exporter.exportTripSamples()
}
} label: {
Label("Export Trip Samples", systemImage: "car.fill")
}
Button { Button {
Task { await exporter.addAllStadiumVisits(modelContext: modelContext) } Task { await exporter.addAllStadiumVisits(modelContext: modelContext) }
} label: { } label: {

View File

@@ -25,8 +25,6 @@ struct ShareCardTypeTests {
#expect(allTypes.contains(.tripSummary)) #expect(allTypes.contains(.tripSummary))
#expect(allTypes.contains(.achievementSpotlight)) #expect(allTypes.contains(.achievementSpotlight))
#expect(allTypes.contains(.achievementCollection)) #expect(allTypes.contains(.achievementCollection))
#expect(allTypes.contains(.achievementMilestone))
#expect(allTypes.contains(.achievementContext))
#expect(allTypes.contains(.stadiumProgress)) #expect(allTypes.contains(.stadiumProgress))
} }
@@ -45,7 +43,7 @@ struct ShareCardTypeTests {
/// - Invariant: Count matches expected number /// - Invariant: Count matches expected number
@Test("Invariant: correct count") @Test("Invariant: correct count")
func invariant_correctCount() { func invariant_correctCount() {
#expect(ShareCardType.allCases.count == 6) #expect(ShareCardType.allCases.count == 4)
} }
} }