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;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTimeDebug.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
@@ -339,8 +339,8 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTime.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;

View File

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

View File

@@ -2,7 +2,9 @@
// AchievementCardGenerator.swift
// 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
@@ -17,18 +19,12 @@ struct AchievementSpotlightContent: ShareableContent {
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementSpotlightView(
achievement: achievement,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
let renderer = ImageRenderer(content: AchievementSpotlightView(achievement: achievement, theme: theme))
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
@@ -38,82 +34,25 @@ struct AchievementSpotlightContent: ShareableContent {
struct AchievementCollectionContent: ShareableContent {
let achievements: [AchievementProgress]
let year: Int
var sports: Set<Sport> = [] // Sports for background icons
var filterSport: Sport? = nil // The sport filter applied (for header title)
var sports: Set<Sport> = []
var filterSport: Sport? = nil
var cardType: ShareCardType { .achievementCollection }
@MainActor
func render(theme: ShareTheme) async throws -> UIImage {
let cardView = AchievementCollectionView(
let renderer = ImageRenderer(content: AchievementCollectionView(
achievements: achievements,
year: year,
sports: sports,
filterSport: filterSport,
theme: theme
)
let renderer = ImageRenderer(content: cardView)
))
renderer.scale = 3.0
guard let image = renderer.uiImage else {
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
}
}
@@ -124,61 +63,61 @@ private struct AchievementSpotlightView: View {
let achievement: AchievementProgress
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)
(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()
// Badge
AchievementBadge(
definition: achievement.definition,
size: 400
isEarned: achievement.earnedAt != nil,
size: 360
)
// Name
Text(achievement.definition.name)
.font(.system(size: 56, weight: .bold, design: .rounded))
Spacer().frame(height: 44)
Text(achievement.definition.name.uppercased())
.font(.system(size: 52, weight: .black))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(3)
.minimumScaleFactor(0.6)
.padding(.horizontal, 60)
Spacer().frame(height: 16)
// Description
Text(achievement.definition.description)
.font(.system(size: 28))
.font(.system(size: 22, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
.multilineTextAlignment(.center)
.padding(.horizontal, 80)
// Unlock date
if let earnedAt = achievement.earnedAt {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(theme.accentColor)
Text("Unlocked \(earnedAt.formatted(date: .abbreviated, time: .omitted))")
}
.font(.system(size: 24))
.foregroundStyle(theme.secondaryTextColor)
if let date = achievement.earnedAt {
Text(date.formatted(date: .abbreviated, time: .omitted).uppercased())
.font(.system(size: 18, weight: .bold))
.tracking(2)
.foregroundStyle(theme.secondaryTextColor)
.padding(.top, 14)
}
Spacer()
ShareCardFooter(theme: theme)
AchievementCardAppFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
}
}
@@ -192,212 +131,69 @@ private struct AchievementCollectionView: View {
let theme: ShareTheme
private let columns = [
GridItem(.flexible(), spacing: 30),
GridItem(.flexible(), spacing: 30),
GridItem(.flexible(), spacing: 30)
GridItem(.flexible(), spacing: 24),
GridItem(.flexible(), spacing: 24),
GridItem(.flexible(), spacing: 24)
]
private var headerTitle: String {
if let sport = filterSport {
return "My \(String(year)) \(sport.rawValue) Achievements"
}
return "My \(String(year)) Achievements"
private var sportLabel: String {
filterSport?.displayName.uppercased() ?? "ALL SPORTS"
}
var body: some View {
ZStack {
ShareCardBackground(theme: theme, sports: sports)
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
VStack(spacing: 40) {
VStack(spacing: 0) {
// Header
Text(headerTitle)
.font(.system(size: 48, weight: .bold, design: .rounded))
Text(sportLabel)
.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)
.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()
// Grid
LazyVGrid(columns: columns, spacing: 40) {
ForEach(achievements.prefix(12)) { achievement in
// Badge grid
LazyVGrid(columns: columns, spacing: 28) {
ForEach(Array(achievements.prefix(9).enumerated()), id: \.offset) { _, item in
VStack(spacing: 12) {
AchievementBadge(
definition: achievement.definition,
size: 200
definition: item.definition,
isEarned: item.earnedAt != nil,
size: 180
)
Text(achievement.definition.name)
.font(.system(size: 18, weight: .semibold))
Text(item.definition.name)
.font(.system(size: 18, weight: .bold))
.foregroundStyle(theme.textColor)
.lineLimit(2)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(height: 44)
}
}
}
.padding(.horizontal, 40)
.padding(.horizontal, 30)
Spacer()
// Count
Text("\(achievements.count) achievements unlocked")
.font(.system(size: 28, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
ShareCardFooter(theme: theme)
AchievementCardAppFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
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
)
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
}
}
@@ -405,57 +201,81 @@ private struct AchievementContextView: View {
private struct AchievementBadge: View {
let definition: AchievementDefinition
let isEarned: Bool
let size: CGFloat
private let gold = Color(hex: "FFD700")
private let goldDark = Color(hex: "B8860B")
var body: some View {
ZStack {
Circle()
.fill(definition.iconColor.opacity(0.2))
RoundedRectangle(cornerRadius: size * 0.22)
.fill(
LinearGradient(
colors: [definition.iconColor.opacity(0.3), definition.iconColor.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: size, height: size)
Circle()
.stroke(definition.iconColor, lineWidth: size * 0.02)
.frame(width: size * 0.9, height: size * 0.9)
RoundedRectangle(cornerRadius: size * 0.22)
.stroke(
LinearGradient(colors: [gold, goldDark], startPoint: .topLeading, endPoint: .bottomTrailing),
lineWidth: 3
)
.frame(width: size, height: size)
Image(systemName: definition.iconName)
.font(.system(size: size * 0.4))
.font(.system(size: size * 0.42, weight: .bold))
.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()
.fill(confettiColor(for: index))
.frame(width: CGFloat.random(in: 8...20))
.position(
x: center.x + xOffset,
y: center.y + yOffset
)
.fill(gold)
.frame(width: size * 0.17, height: size * 0.17)
.overlay {
Image(systemName: "checkmark")
.font(.system(size: size * 0.078, weight: .black))
.foregroundStyle(Color.black.opacity(0.75))
}
.offset(x: size * 0.35, y: -size * 0.35)
}
}
}
private func confettiColor(for index: Int) -> Color {
let colors: [Color] = [
Color(hex: "FFD700"),
Color(hex: "FF6B35"),
Color(hex: "00D4FF"),
Color(hex: "95D5B2"),
Color(hex: "FF85A1")
]
return colors[index % colors.count]
.frame(width: size, height: size)
.shadow(color: gold.opacity(0.3), radius: 12, y: 6)
}
}
// MARK: - App Footer
private struct AchievementCardAppFooter: 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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,8 @@
// ProgressCardGenerator.swift
// 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
@@ -25,20 +26,17 @@ struct ProgressShareContent: ShareableContent {
theme: theme
)
let cardView = ProgressCardView(
let renderer = ImageRenderer(content: ProgressCardView(
progress: progress,
tripCount: tripCount,
theme: theme,
mapSnapshot: mapSnapshot
)
let renderer = ImageRenderer(content: cardView)
))
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
@@ -51,62 +49,146 @@ private struct ProgressCardView: View {
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 {
ShareCardBackground(theme: theme, sports: [progress.sport])
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
VStack(spacing: 40) {
ShareCardHeader(
title: "\(progress.sport.displayName) Stadium Quest",
sport: progress.sport,
theme: theme
)
Spacer()
// Progress ring
ShareProgressRing(
current: progress.visitedStadiums,
total: progress.totalStadiums,
theme: theme
)
Text("\(Int(progress.completionPercentage))% Complete")
.font(.system(size: 28, weight: .medium))
VStack(spacing: 0) {
// Header
Text(progress.sport.displayName.uppercased())
.font(.system(size: 20, weight: .black))
.tracking(8)
.foregroundStyle(theme.secondaryTextColor)
.padding(.top, 20)
// Stats row
ShareStatsRow(
stats: [
(value: "\(progress.visitedStadiums)", label: "visited"),
(value: "\(progress.totalStadiums - progress.visitedStadiums)", label: "remain"),
(value: "\(tripCount)", label: "trips")
],
theme: theme
)
Text("STADIUM QUEST")
.font(.system(size: 44, weight: .black))
.foregroundStyle(theme.textColor)
.padding(.top, 8)
// Map
if let snapshot = mapSnapshot {
Image(uiImage: snapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 960)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(theme.accentColor.opacity(0.3), lineWidth: 2)
}
if isComplete {
Text("COMPLETE")
.font(.system(size: 18, weight: .black))
.tracking(4)
.foregroundStyle(gold)
.padding(.top, 6)
}
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)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
.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: - 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
// SportsTime
//
// Reusable components for share cards: header, footer, stats row, map snapshot.
// Shared building blocks for the new shareable card system.
//
import SwiftUI
@@ -16,15 +16,7 @@ struct ShareCardBackground: View {
var sports: Set<Sport>? = nil
var body: some View {
if let sports = sports, !sports.isEmpty {
ShareCardSportBackground(sports: sports, theme: theme)
} else {
LinearGradient(
colors: theme.gradientColors,
startPoint: .top,
endPoint: .bottom
)
}
ShareCardSportBackground(sports: sports ?? [], theme: theme)
}
}
@@ -36,24 +28,62 @@ struct ShareCardHeader: View {
let theme: ShareTheme
var body: some View {
VStack(spacing: 16) {
if let sport = sport {
ZStack {
Circle()
.fill(theme.accentColor.opacity(0.2))
.frame(width: 80, height: 80)
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .center, spacing: 18) {
if let sport = sport {
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(theme.accentColor.opacity(0.22))
.frame(width: 88, height: 88)
Image(systemName: sport.iconName)
.font(.system(size: 40))
.foregroundStyle(theme.accentColor)
RoundedRectangle(cornerRadius: 20)
.stroke(theme.accentColor.opacity(0.65), lineWidth: 1.5)
.frame(width: 88, height: 88)
Image(systemName: sport.iconName)
.font(.system(size: 40, weight: .bold))
.foregroundStyle(theme.accentColor)
}
.shadow(color: .black.opacity(0.22), radius: 10, y: 6)
}
VStack(alignment: .leading, spacing: 7) {
Text("SPORTSTIME")
.font(.system(size: 15, weight: .bold))
.tracking(5)
.foregroundStyle(theme.secondaryTextColor)
Text(title)
.font(.system(size: 47, weight: .black, design: .default))
.foregroundStyle(theme.textColor)
.lineLimit(2)
.minimumScaleFactor(0.65)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
Text(title)
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(theme.textColor)
.multilineTextAlignment(.center)
Capsule()
.fill(
LinearGradient(
colors: theme.highlightGradient,
startPoint: .leading,
endPoint: .trailing
)
)
.frame(height: 4)
}
.padding(.horizontal, 26)
.padding(.vertical, 22)
.background(
RoundedRectangle(cornerRadius: 30)
.fill(theme.surfaceColor)
.overlay(
RoundedRectangle(cornerRadius: 30)
.stroke(theme.borderColor, lineWidth: 1)
)
)
}
}
@@ -63,19 +93,32 @@ struct ShareCardFooter: View {
let theme: ShareTheme
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "sportscourt.fill")
.font(.system(size: 20))
Text("SportsTime")
.font(.system(size: 24, weight: .semibold))
}
.foregroundStyle(theme.accentColor)
HStack(spacing: 14) {
Text("SPORTSTIME")
.font(.system(size: 16, weight: .black))
.tracking(3.5)
.foregroundStyle(theme.accentColor)
Text("Plan your stadium adventure")
.font(.system(size: 18))
Capsule()
.fill(theme.borderColor)
.frame(width: 26, height: 2)
Text("build your next game-day route")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
Spacer(minLength: 0)
}
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(theme.surfaceColor)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(theme.borderColor, lineWidth: 1)
)
)
}
}
@@ -86,25 +129,33 @@ struct ShareStatsRow: View {
let theme: ShareTheme
var body: some View {
HStack(spacing: 60) {
HStack(spacing: 14) {
ForEach(Array(stats.enumerated()), id: \.offset) { _, stat in
VStack(spacing: 8) {
VStack(spacing: 7) {
Text(stat.value)
.font(.system(size: 36, weight: .bold, design: .rounded))
.font(.system(size: 44, weight: .black, design: .rounded))
.foregroundStyle(theme.accentColor)
.minimumScaleFactor(0.7)
.lineLimit(1)
Text(stat.label)
.font(.system(size: 20))
Text(stat.label.uppercased())
.font(.system(size: 13, weight: .bold))
.tracking(2.2)
.foregroundStyle(theme.secondaryTextColor)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(theme.surfaceColor)
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(theme.borderColor, lineWidth: 1)
)
)
}
}
.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 total: Int
let theme: ShareTheme
var size: CGFloat = 320
var lineWidth: CGFloat = 24
var size: CGFloat = 340
var lineWidth: CGFloat = 30
private let segmentCount = 72
private var progress: Double {
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 {
ZStack {
// Background ring
Circle()
.stroke(theme.accentColor.opacity(0.2), lineWidth: lineWidth)
.frame(width: size, height: size)
ForEach(0..<segmentCount, id: \.self) { index in
Capsule()
.fill(index < filledSegments ? theme.accentColor : theme.surfaceColor.opacity(0.90))
.frame(width: lineWidth * 0.62, height: lineWidth * 1.18)
.offset(y: -(size / 2 - lineWidth * 0.78))
.rotationEffect(.degrees(Double(index) * 360 / Double(segmentCount)))
}
// Progress ring
Circle()
.trim(from: 0, to: progress)
.stroke(
theme.accentColor,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.frame(width: size, height: size)
.rotationEffect(.degrees(-90))
ForEach([0.25, 0.50, 0.75], id: \.self) { mark in
Circle()
.fill(theme.textColor.opacity(0.32))
.frame(width: 8, height: 8)
.offset(y: -(size / 2 - lineWidth * 0.20))
.rotationEffect(.degrees(mark * 360))
}
// Center content
VStack(spacing: 8) {
Circle()
.stroke(theme.glowColor.opacity(0.45), lineWidth: 14)
.blur(radius: 12)
.frame(width: size - lineWidth * 1.4, height: size - lineWidth * 1.4)
VStack(spacing: 4) {
Text("\(current)")
.font(.system(size: 96, weight: .bold, design: .rounded))
.font(.system(size: 108, weight: .black, design: .rounded))
.foregroundStyle(theme.textColor)
Text("of \(total)")
.font(.system(size: 32, weight: .medium))
.font(.system(size: 27, weight: .light))
.foregroundStyle(theme.secondaryTextColor)
}
}
.frame(width: size, height: size)
}
}
@@ -158,7 +221,6 @@ struct ShareProgressRing: View {
@MainActor
final class ShareMapSnapshotGenerator {
/// Generate a progress map showing visited/remaining stadiums
func generateProgressMap(
visited: [Stadium],
remaining: [Stadium],
@@ -167,9 +229,8 @@ final class ShareMapSnapshotGenerator {
let allStadiums = visited + remaining
guard !allStadiums.isEmpty else { return nil }
let region = calculateRegion(for: allStadiums)
let options = MKMapSnapshotter.Options()
options.region = region
options.region = calculateRegion(for: allStadiums)
options.size = ShareCardDimensions.mapSnapshotSize
options.mapType = theme.useDarkMap ? .mutedStandard : .standard
@@ -188,19 +249,15 @@ final class ShareMapSnapshotGenerator {
}
}
/// Generate a route map for trip cards
func generateRouteMap(
stops: [TripStop],
theme: ShareTheme
) async -> UIImage? {
let stopsWithCoordinates = stops.filter { $0.coordinate != nil }
guard stopsWithCoordinates.count >= 2 else { return nil }
let validStops = stops.filter { $0.coordinate != nil }
guard validStops.count >= 2 else { return nil }
let coordinates = stopsWithCoordinates.compactMap { $0.coordinate }
let region = calculateRegion(for: coordinates)
let options = MKMapSnapshotter.Options()
options.region = region
options.region = calculateRegion(for: validStops.compactMap { $0.coordinate })
options.size = ShareCardDimensions.routeMapSize
options.mapType = theme.useDarkMap ? .mutedStandard : .standard
@@ -208,18 +265,12 @@ final class ShareMapSnapshotGenerator {
do {
let snapshot = try await snapshotter.start()
return drawRoute(
on: snapshot,
stops: stopsWithCoordinates,
accentColor: UIColor(theme.accentColor)
)
return drawRoute(on: snapshot, stops: validStops, accentColor: UIColor(theme.accentColor))
} catch {
return nil
}
}
// MARK: - Private Helpers
private func calculateRegion(for stadiums: [Stadium]) -> MKCoordinateRegion {
let coordinates = stadiums.map {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
@@ -233,17 +284,16 @@ final class ShareMapSnapshotGenerator {
let minLon = coordinates.map(\.longitude).min() ?? 0
let maxLon = coordinates.map(\.longitude).max() ?? 0
let center = CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
return MKCoordinateRegion(
center: CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
),
span: MKCoordinateSpan(
latitudeDelta: max((maxLat - minLat) * 1.35, 1),
longitudeDelta: max((maxLon - minLon) * 1.35, 1)
)
)
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(
@@ -252,26 +302,25 @@ final class ShareMapSnapshotGenerator {
remaining: [Stadium],
accentColor: UIColor
) -> UIImage {
let size = ShareCardDimensions.mapSnapshotSize
return UIGraphicsImageRenderer(size: size).image { context in
UIGraphicsImageRenderer(size: ShareCardDimensions.mapSnapshotSize).image { context in
snapshot.image.draw(at: .zero)
// Draw remaining (gray) first
for stadium in remaining {
let point = snapshot.point(for: CLLocationCoordinate2D(
latitude: stadium.latitude,
longitude: stadium.longitude
))
drawMarker(at: point, color: .gray, context: context.cgContext)
drawStadiumDot(
at: snapshot.point(for: CLLocationCoordinate2D(latitude: stadium.latitude, longitude: stadium.longitude)),
color: UIColor.systemGray3,
visited: false,
context: context.cgContext
)
}
// Draw visited (accent) on top
for stadium in visited {
let point = snapshot.point(for: CLLocationCoordinate2D(
latitude: stadium.latitude,
longitude: stadium.longitude
))
drawMarker(at: point, color: accentColor, context: context.cgContext)
drawStadiumDot(
at: snapshot.point(for: CLLocationCoordinate2D(latitude: stadium.latitude, longitude: stadium.longitude)),
color: accentColor,
visited: true,
context: context.cgContext
)
}
}
}
@@ -281,126 +330,125 @@ final class ShareMapSnapshotGenerator {
stops: [TripStop],
accentColor: UIColor
) -> UIImage {
let size = ShareCardDimensions.routeMapSize
return UIGraphicsImageRenderer(size: size).image { context in
UIGraphicsImageRenderer(size: ShareCardDimensions.routeMapSize).image { context in
snapshot.image.draw(at: .zero)
let cgContext = context.cgContext
// Draw route line
cgContext.setStrokeColor(accentColor.cgColor)
cgContext.setLineWidth(4)
cgContext.setLineCap(.round)
cgContext.setLineJoin(.round)
let cg = context.cgContext
let points = stops.compactMap { stop -> CGPoint? in
guard let coord = stop.coordinate else { return nil }
return snapshot.point(for: coord)
}
if let first = points.first {
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() {
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() {
guard let coord = stop.coordinate else { continue }
let point = snapshot.point(for: coord)
drawCityMarker(
let isFirst = index == 0
let isLast = index == stops.count - 1
drawCityLabel(
at: point,
label: String(stop.city.prefix(3)).uppercased(),
isFirst: index == 0,
isLast: index == stops.count - 1,
label: isFirst ? "START" : isLast ? "FINISH" : String(stop.city.prefix(3)).uppercased(),
endpoint: isFirst || isLast,
color: accentColor,
context: cgContext
context: cg
)
}
}
}
private func drawMarker(at point: CGPoint, color: UIColor, context: CGContext) {
let markerSize: CGFloat = 16
private func drawStadiumDot(
at point: CGPoint,
color: UIColor,
visited: Bool,
context: CGContext
) {
let size: CGFloat = 22
context.setFillColor(UIColor.black.withAlphaComponent(0.28).cgColor)
context.fillEllipse(in: CGRect(x: point.x - size / 2 - 3, y: point.y - size / 2 + 2, width: size + 6, height: size + 6))
context.setFillColor(UIColor.white.cgColor)
context.fillEllipse(in: CGRect(x: point.x - size / 2 - 2, y: point.y - size / 2 - 2, width: size + 4, height: size + 4))
context.setFillColor(color.cgColor)
context.fillEllipse(in: CGRect(
x: point.x - markerSize / 2,
y: point.y - markerSize / 2,
width: markerSize,
height: markerSize
))
context.fillEllipse(in: CGRect(x: point.x - size / 2, y: point.y - size / 2, width: size, height: size))
context.setStrokeColor(UIColor.white.cgColor)
context.setLineWidth(2)
context.strokeEllipse(in: CGRect(
x: point.x - markerSize / 2,
y: point.y - markerSize / 2,
width: markerSize,
height: markerSize
))
if visited {
context.setStrokeColor(UIColor.white.cgColor)
context.setLineWidth(2.6)
context.setLineCap(.round)
context.setLineJoin(.round)
context.move(to: CGPoint(x: point.x - 4.5, y: point.y + 0.5))
context.addLine(to: CGPoint(x: point.x - 0.8, y: point.y + 4.6))
context.addLine(to: CGPoint(x: point.x + 6.2, y: point.y - 3.2))
context.strokePath()
}
}
private func drawCityMarker(
private func drawCityLabel(
at point: CGPoint,
label: String,
isFirst: Bool,
isLast: Bool,
endpoint: Bool,
color: UIColor,
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.fillEllipse(in: CGRect(
x: point.x - markerSize / 2,
y: point.y - markerSize / 2,
width: markerSize,
height: markerSize
))
context.fillEllipse(in: CGRect(x: point.x - dotSize / 2, y: point.y - dotSize / 2, width: dotSize, height: dotSize))
// White border
context.setStrokeColor(UIColor.white.cgColor)
context.setLineWidth(3)
context.strokeEllipse(in: CGRect(
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
let font = UIFont.systemFont(ofSize: endpoint ? 12.5 : 11, weight: .heavy)
let attrs: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: UIColor.white
]
// Draw label background
let labelBgRect = CGRect(
x: point.x - 22,
y: point.y - markerSize / 2 - 24,
width: 44,
height: 18
let textSize = (label as NSString).size(withAttributes: attrs)
let bgRect = CGRect(
x: point.x - textSize.width / 2 - 11,
y: point.y - dotSize / 2 - textSize.height - 12,
width: textSize.width + 22,
height: textSize.height + 8
)
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.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
// 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
@@ -11,70 +12,202 @@ struct ShareCardSportBackground: View {
let sports: Set<Sport>
let theme: ShareTheme
/// Fixed positions for 12 scattered icons (x, y as percentage, rotation, scale)
private let iconConfigs: [(x: CGFloat, y: CGFloat, rotation: Double, scale: CGFloat)] = [
(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
private var primarySport: Sport? {
sports.sorted { $0.rawValue < $1.rawValue }.first
}
var body: some View {
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(
colors: theme.gradientColors,
colors: [
.black.opacity(0.20),
.clear,
.black.opacity(0.30)
],
startPoint: .top,
endPoint: .bottom
)
}
}
// Scattered sport icons
GeometryReader { geo in
ForEach(0..<iconConfigs.count, id: \.self) { index in
let config = iconConfigs[index]
Image(systemName: iconName(at: index))
.font(.system(size: 32 * config.scale))
.foregroundStyle(theme.accentColor.opacity(0.15))
.rotationEffect(.degrees(config.rotation))
.position(
x: geo.size.width * config.x,
y: geo.size.height * config.y
)
}
private var glowLayer: some View {
GeometryReader { geo in
ZStack {
Circle()
.fill(theme.accentColor.opacity(0.24))
.frame(width: geo.size.width * 0.95)
.offset(x: -geo.size.width * 0.30, y: -geo.size.height * 0.35)
Circle()
.fill(theme.textColor.opacity(0.10))
.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") {
ShareCardSportBackground(
sports: [.mlb],
theme: .sunset
)
.frame(width: 400, height: 600)
private struct BaseballStitchPattern: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let rowStep: CGFloat = 150
let colStep: CGFloat = 220
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") {
ShareCardSportBackground(
sports: [.mlb, .nba, .nfl],
theme: .dark
)
.frame(width: 400, height: 600)
private struct CourtArcPattern: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let spacing: CGFloat = 170
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 {
case .tripSummary:
return tripTheme
case .achievementSpotlight, .achievementCollection, .achievementMilestone, .achievementContext:
case .achievementSpotlight, .achievementCollection:
return achievementTheme
case .stadiumProgress:
return progressTheme
@@ -112,7 +112,7 @@ enum ShareThemePreferences {
switch cardType {
case .tripSummary:
tripTheme = theme
case .achievementSpotlight, .achievementCollection, .achievementMilestone, .achievementContext:
case .achievementSpotlight, .achievementCollection:
achievementTheme = theme
case .stadiumProgress:
progressTheme = theme

View File

@@ -21,8 +21,6 @@ enum ShareCardType: String, CaseIterable {
case tripSummary
case achievementSpotlight
case achievementCollection
case achievementMilestone
case achievementContext
case stadiumProgress
}
@@ -124,6 +122,72 @@ struct ShareTheme: Identifiable, Hashable {
static func theme(byId id: String) -> ShareTheme {
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

View File

@@ -2,7 +2,8 @@
// TripCardGenerator.swift
// SportsTime
//
// Generates shareable trip summary cards with route map.
// Shareable trip cards unified design language.
// Solid color bg, plain white text, no panels/borders, app icon footer.
//
import SwiftUI
@@ -23,19 +24,12 @@ struct TripShareContent: ShareableContent {
theme: theme
)
let cardView = TripCardView(
trip: trip,
theme: theme,
mapSnapshot: mapSnapshot
)
let renderer = ImageRenderer(content: cardView)
let renderer = ImageRenderer(content: TripCardView(trip: trip, theme: theme, mapSnapshot: mapSnapshot))
renderer.scale = 3.0
guard let image = renderer.uiImage else {
throw ShareError.renderingFailed
}
return image
}
}
@@ -47,80 +41,169 @@ private struct TripCardView: View {
let theme: ShareTheme
let mapSnapshot: UIImage?
private var sportTitle: String {
if trip.uniqueSports.count == 1, let sport = trip.uniqueSports.first {
return "My \(sport.displayName) Road Trip"
}
return "My Sports Road Trip"
private var sortedSports: [Sport] {
trip.uniqueSports.sorted { $0.rawValue < $1.rawValue }
}
private var primarySport: Sport? {
trip.uniqueSports.first
/// Map each unique city to its stadium name(s) from the stops.
private var cityStadiums: [String: String] {
var result: [String: String] = [:]
for city in trip.cities {
let stadiums = trip.stops
.filter { $0.city == city }
.compactMap { $0.stadium }
let unique = Array(Set(stadiums)).sorted()
if !unique.isEmpty {
result[city] = unique.joined(separator: " & ")
}
}
return result
}
var body: some View {
ZStack {
ShareCardBackground(theme: theme, sports: trip.uniqueSports)
(theme.gradientColors.first ?? .black)
.ignoresSafeArea()
VStack(spacing: 40) {
ShareCardHeader(
title: sportTitle,
sport: primarySport,
theme: theme
)
VStack(spacing: 0) {
// Header
Text(sortedSports.map { $0.displayName.uppercased() }.joined(separator: " + "))
.font(.system(size: 20, weight: .black))
.tracking(8)
.foregroundStyle(theme.secondaryTextColor)
.padding(.top, 20)
// Map
if let snapshot = mapSnapshot {
Image(uiImage: snapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 960, maxHeight: 600)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(theme.accentColor.opacity(0.3), lineWidth: 2)
}
}
// Date range
Text(trip.formattedDateRange)
.font(.system(size: 32, weight: .medium))
Text("ROAD TRIP")
.font(.system(size: 52, weight: .black))
.foregroundStyle(theme.textColor)
.padding(.top, 8)
// Stats row
ShareStatsRow(
stats: [
(value: String(format: "%.0f", trip.totalDistanceMiles), label: "miles"),
(value: "\(trip.totalGames)", label: "games"),
(value: "\(trip.cities.count)", label: "cities")
],
theme: theme
)
// City trail
cityTrail
Text(trip.formattedDateRange.uppercased())
.font(.system(size: 18, weight: .bold))
.tracking(2)
.foregroundStyle(theme.secondaryTextColor)
.padding(.top, 8)
Spacer()
ShareCardFooter(theme: theme)
// Map
if let mapSnapshot {
Image(uiImage: mapSnapshot)
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 24))
.padding(.horizontal, 16)
}
Spacer()
// Stats
HStack(spacing: 40) {
statItem(value: String(format: "%.0f", trip.totalDistanceMiles), label: "MILES")
statItem(value: "\(trip.totalGames)", label: "GAMES")
statItem(value: "\(trip.cities.count)", label: "CITIES")
statItem(value: "\(trip.tripDuration)", label: "DAYS")
}
Spacer()
// City trail
VStack(spacing: 0) {
ForEach(Array(trip.cities.enumerated()), id: \.offset) { index, city in
HStack(spacing: 20) {
ZStack {
Circle()
.fill(theme.accentColor)
.frame(width: 48, height: 48)
Text("\(index + 1)")
.font(.system(size: 22, weight: .black, design: .rounded))
.foregroundStyle(theme.gradientColors.first ?? .black)
}
VStack(alignment: .leading, spacing: 4) {
Text(city.uppercased())
.font(.system(size: 32, weight: .black))
.foregroundStyle(theme.textColor)
.lineLimit(1)
.minimumScaleFactor(0.6)
if let stadium = cityStadiums[city] {
Text(stadium)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
.lineLimit(1)
.minimumScaleFactor(0.6)
}
}
Spacer()
}
if index < trip.cities.count - 1 {
HStack(spacing: 20) {
Rectangle()
.fill(theme.textColor.opacity(0.15))
.frame(width: 2, height: 24)
.padding(.leading, 23)
Spacer()
}
}
}
}
.padding(.horizontal, 40)
Spacer()
TripCardAppFooter(theme: theme)
}
.padding(ShareCardDimensions.padding)
}
.frame(
width: ShareCardDimensions.cardSize.width,
height: ShareCardDimensions.cardSize.height
)
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
}
private var cityTrail: some View {
let cities = trip.cities
let displayText = cities.joined(separator: "")
return Text(displayText)
.font(.system(size: 24, weight: .medium))
.foregroundStyle(theme.secondaryTextColor)
.multilineTextAlignment(.center)
.lineLimit(3)
.padding(.horizontal, 40)
private func statItem(value: String, label: String) -> some View {
VStack(spacing: 6) {
Text(value)
.font(.system(size: 52, weight: .black, design: .rounded))
.foregroundStyle(theme.accentColor)
.minimumScaleFactor(0.6)
Text(label)
.font(.system(size: 16, weight: .bold))
.tracking(2)
.foregroundStyle(theme.secondaryTextColor)
}
}
}
// MARK: - App Footer
private struct TripCardAppFooter: View {
let theme: ShareTheme
var body: some View {
VStack(spacing: 8) {
if let icon = Self.loadAppIcon() {
Image(uiImage: icon)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 13))
}
Text("SportsTime")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(theme.textColor.opacity(0.5))
}
.padding(.bottom, 10)
}
private static func loadAppIcon() -> UIImage? {
if let icons = Bundle.main.infoDictionary?["CFBundleIcons"] as? [String: Any],
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
let files = primary["CFBundleIconFiles"] as? [String],
let name = files.last {
return UIImage(named: name)
}
return nil
}
}

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 total = displayAchievements.count
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 accentColor = selectedSport?.themeColor ?? Theme.warmOrange
@@ -297,8 +297,10 @@ struct AchievementCard: View {
@Environment(\.colorScheme) private var colorScheme
// Gold color for completed achievements
private let completedGold = Color(hex: "FFD700")
// Gold that's readable in both light and dark mode
private var completedGold: Color {
colorScheme == .dark ? Color(hex: "FFD700") : Color(hex: "B8860B")
}
var body: some View {
VStack(spacing: Theme.Spacing.sm) {
@@ -460,8 +462,10 @@ struct AchievementDetailSheet: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
// Gold color for completed achievements
private let completedGold = Color(hex: "FFD700")
// Gold that's readable in both light and dark mode
private var completedGold: Color {
colorScheme == .dark ? Color(hex: "FFD700") : Color(hex: "B8860B")
}
var body: some View {
NavigationStack {

View File

@@ -34,8 +34,8 @@ final class DebugShareExporter {
exportedCount = 0
let achievementCount = AchievementRegistry.all.count
// spotlight + milestone + context = 3 * achievements, collection ~5, progress 12, trips 4, icons 1
totalCount = (achievementCount * 3) + 5 + 12 + 4 + 1
// spotlight = 1 * achievements, collection ~5, progress 12, trips 4, icons 1
totalCount = achievementCount + 5 + 12 + 4 + 1
do {
// Step 1: Create export directory
@@ -51,13 +51,10 @@ final class DebugShareExporter {
let engine = AchievementEngine(modelContext: modelContext)
_ = try await engine.recalculateAllAchievements()
// Step 4: Export achievement spotlight + milestone cards
// Step 4: Export achievement spotlight cards
currentStep = "Exporting spotlight cards..."
let spotlightTheme = ShareThemePreferences.theme(for: .achievementSpotlight)
let milestoneTheme = ShareThemePreferences.theme(for: .achievementMilestone)
let spotlightDir = exportDir.appendingPathComponent("achievements/spotlight")
let milestoneDir = exportDir.appendingPathComponent("achievements/milestone")
for definition in AchievementRegistry.all {
let achievement = AchievementProgress(
@@ -68,21 +65,12 @@ final class DebugShareExporter {
earnedAt: Date()
)
// Spotlight
currentStep = "Spotlight: \(definition.name)"
let spotlightContent = AchievementSpotlightContent(achievement: achievement)
let spotlightImage = try await spotlightContent.render(theme: spotlightTheme)
try savePNG(spotlightImage, to: spotlightDir.appendingPathComponent("\(definition.id).png"))
exportedCount += 1
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
@@ -139,36 +127,7 @@ final class DebugShareExporter {
exportedCount += 1
updateProgress()
// Step 6: Export achievement context 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
// Step 6: Export progress cards
currentStep = "Exporting progress cards..."
let progressTheme = ShareThemePreferences.theme(for: .stadiumProgress)
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..."
let tripTheme = ShareThemePreferences.theme(for: .tripSummary)
let tripDir = exportDir.appendingPathComponent("trips")
@@ -220,7 +179,7 @@ final class DebugShareExporter {
updateProgress()
}
// Step 9: Export sports icon
// Step 8: Export sports icon
currentStep = "Exporting sports icon..."
let iconDir = exportDir.appendingPathComponent("icons")
if let iconData = SportsIconImageGenerator.generatePNGData(),
@@ -244,6 +203,184 @@ final class DebugShareExporter {
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
func addAllStadiumVisits(modelContext: ModelContext) async {
@@ -311,9 +448,7 @@ final class DebugShareExporter {
let subdirs = [
"achievements/spotlight",
"achievements/milestone",
"achievements/collection",
"achievements/context",
"progress",
"trips",
"icons"

View File

@@ -360,6 +360,33 @@ struct SettingsView: View {
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 {
Task { await exporter.addAllStadiumVisits(modelContext: modelContext) }
} label: {

View File

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