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