diff --git a/SportsTime.xcodeproj/project.pbxproj b/SportsTime.xcodeproj/project.pbxproj index 1f49d24..07da0ad 100644 --- a/SportsTime.xcodeproj/project.pbxproj +++ b/SportsTime.xcodeproj/project.pbxproj @@ -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; diff --git a/SportsTime/Core/Theme/SportSelectorGrid.swift b/SportsTime/Core/Theme/SportSelectorGrid.swift index 38ec6a8..60702e0 100644 --- a/SportsTime/Core/Theme/SportSelectorGrid.swift +++ b/SportsTime/Core/Theme/SportSelectorGrid.swift @@ -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) diff --git a/SportsTime/Export/Sharing/AchievementCardGenerator.swift b/SportsTime/Export/Sharing/AchievementCardGenerator.swift index 1ebb34b..82c8c96 100644 --- a/SportsTime/Export/Sharing/AchievementCardGenerator.swift +++ b/SportsTime/Export/Sharing/AchievementCardGenerator.swift @@ -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 = [] // Sports for background icons - var filterSport: Sport? = nil // The sport filter applied (for header title) + var sports: Set = [] + 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 { - 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 { - 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 { - 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 } } diff --git a/SportsTime/Export/Sharing/AchievementDesignSamples.swift b/SportsTime/Export/Sharing/AchievementDesignSamples.swift new file mode 100644 index 0000000..7218250 --- /dev/null +++ b/SportsTime/Export/Sharing/AchievementDesignSamples.swift @@ -0,0 +1,1203 @@ +// +// AchievementDesignSamples.swift +// SportsTime +// +// 5 design explorations for achievement cards. Render all via Debug > Export Samples. +// Pick the best parts from each to create the final design. +// + +#if DEBUG + +import SwiftUI +import UIKit + +// MARK: - Sample Renderer + +@MainActor +struct AchievementSampleRenderer { + + static func renderAll( + achievement: AchievementProgress, + milestoneAchievement: AchievementProgress, + theme: ShareTheme + ) async throws -> [(label: String, image: UIImage)] { + var results: [(String, UIImage)] = [] + + let spotlightViews: [(String, AnyView)] = [ + ("A_Broadcast_Spotlight", AnyView(SampleA_Spotlight(achievement: achievement, theme: theme))), + ("B_Editorial_Spotlight", AnyView(SampleB_Spotlight(achievement: achievement, theme: theme))), + ("C_Trophy_Spotlight", AnyView(SampleC_Spotlight(achievement: achievement, theme: theme))), + ("D_Poster_Spotlight", AnyView(SampleD_Spotlight(achievement: achievement, theme: theme))), + ("E_Ticket_Spotlight", AnyView(SampleE_Spotlight(achievement: achievement, theme: theme))), + ] + + let milestoneViews: [(String, AnyView)] = [ + ("A_Broadcast_Milestone", AnyView(SampleA_Milestone(achievement: milestoneAchievement, theme: theme))), + ("B_Editorial_Milestone", AnyView(SampleB_Milestone(achievement: milestoneAchievement, theme: theme))), + ("C_Trophy_Milestone", AnyView(SampleC_Milestone(achievement: milestoneAchievement, theme: theme))), + ("D_Poster_Milestone", AnyView(SampleD_Milestone(achievement: milestoneAchievement, theme: theme))), + ("E_Ticket_Milestone", AnyView(SampleE_Milestone(achievement: milestoneAchievement, theme: theme))), + ] + + for (label, view) in spotlightViews + milestoneViews { + let renderer = ImageRenderer(content: view) + renderer.scale = 3.0 + if let image = renderer.uiImage { + results.append((label, image)) + } + } + + return results + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// MARK: - SAMPLE A: "Broadcast" +// ESPN-style scoreboard. Colored top banner, badge in a dark panel with +// subtle grid behind it, bold condensed name, stat-block earned date. +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +private struct SampleA_Spotlight: View { + let achievement: AchievementProgress + let theme: ShareTheme + + var body: some View { + ZStack { + ShareCardBackground(theme: theme, sports: achievement.sportSet) + + VStack(spacing: 0) { + // Top banner bar + HStack { + if let sport = achievement.definition.sport { + Image(systemName: sport.iconName) + .font(.system(size: 28, weight: .bold)) + } + Text("ACHIEVEMENT UNLOCKED") + .font(.system(size: 22, weight: .black)) + .tracking(3) + Spacer() + } + .foregroundStyle(.white) + .padding(.horizontal, 28) + .padding(.vertical, 20) + .background(theme.accentColor) + + Spacer() + + // Badge in a dark panel with grid + ZStack { + // Subtle grid + GridPatternA() + .stroke(theme.textColor.opacity(0.06), lineWidth: 1) + + SampleBadgeCircle( + definition: achievement.definition, + size: 360 + ) + } + .frame(height: 500) + .padding(.horizontal, 40) + .background( + RoundedRectangle(cornerRadius: 24) + .fill(.black.opacity(0.3)) + ) + .padding(.horizontal, 40) + + Spacer().frame(height: 40) + + // Name — bold condensed + Text(achievement.definition.name.uppercased()) + .font(.system(size: 58, weight: .black)) + .foregroundStyle(theme.textColor) + .multilineTextAlignment(.center) + .lineLimit(3) + .minimumScaleFactor(0.6) + .padding(.horizontal, 60) + + Spacer().frame(height: 16) + + // Description + Text(achievement.definition.description) + .font(.system(size: 24, weight: .medium)) + .foregroundStyle(theme.secondaryTextColor) + .multilineTextAlignment(.center) + .padding(.horizontal, 80) + + Spacer().frame(height: 30) + + // Stat-block earned date + if let date = achievement.earnedAt { + HStack(spacing: 40) { + VStack(spacing: 4) { + Text(date.formatted(.dateTime.month(.abbreviated)).uppercased()) + .font(.system(size: 36, weight: .black, design: .rounded)) + .foregroundStyle(theme.accentColor) + Text("MONTH") + .font(.system(size: 14, weight: .bold)) + .tracking(2) + .foregroundStyle(theme.secondaryTextColor) + } + Rectangle() + .fill(theme.borderColor) + .frame(width: 1, height: 50) + VStack(spacing: 4) { + Text(date.formatted(.dateTime.year())) + .font(.system(size: 36, weight: .black, design: .rounded)) + .foregroundStyle(theme.accentColor) + Text("YEAR") + .font(.system(size: 14, weight: .bold)) + .tracking(2) + .foregroundStyle(theme.secondaryTextColor) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 40) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(theme.surfaceColor) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(theme.borderColor, lineWidth: 1)) + ) + } + + Spacer() + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height) + } +} + +private struct SampleA_Milestone: View { + let achievement: AchievementProgress + let theme: ShareTheme + private let gold = Color(hex: "FFD700") + private let goldDark = Color(hex: "B8860B") + + var body: some View { + ZStack { + ShareCardBackground(theme: theme, sports: achievement.sportSet) + + VStack(spacing: 0) { + // Gold milestone banner + HStack { + Image(systemName: "trophy.fill") + .font(.system(size: 24, weight: .bold)) + Text("MILESTONE ACHIEVEMENT") + .font(.system(size: 20, weight: .black)) + .tracking(3) + Spacer() + } + .foregroundStyle(.black) + .padding(.horizontal, 28) + .padding(.vertical, 18) + .background( + LinearGradient(colors: [gold, goldDark], startPoint: .leading, endPoint: .trailing) + ) + + Spacer() + + // Double gold ring around badge + ZStack { + Circle() + .stroke( + LinearGradient(colors: [gold, goldDark, gold], startPoint: .topLeading, endPoint: .bottomTrailing), + lineWidth: 8 + ) + .frame(width: 440, height: 440) + + Circle() + .stroke(gold.opacity(0.3), lineWidth: 2) + .frame(width: 460, height: 460) + + SampleBadgeCircle(definition: achievement.definition, size: 400) + } + + Spacer().frame(height: 40) + + Text(achievement.definition.name.uppercased()) + .font(.system(size: 52, weight: .black)) + .foregroundStyle(theme.textColor) + .multilineTextAlignment(.center) + .lineLimit(3) + .minimumScaleFactor(0.6) + .padding(.horizontal, 60) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(gold.opacity(0.1)) + ) + + Spacer().frame(height: 16) + + Text(achievement.definition.description) + .font(.system(size: 24, weight: .medium)) + .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: - SAMPLE B: "Editorial" +// Magazine-style. Asymmetric, badge on left, name on right in large heavy +// type. Thin hairline rules. Generous whitespace. Refined and quiet. +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +private struct SampleB_Spotlight: View { + let achievement: AchievementProgress + let theme: ShareTheme + + var body: some View { + ZStack { + ShareCardBackground(theme: theme, sports: achievement.sportSet) + + VStack(spacing: 0) { + // Minimal top label + HStack { + Text("ACHIEVEMENT") + .font(.system(size: 14, weight: .bold)) + .tracking(6) + .foregroundStyle(theme.secondaryTextColor) + Spacer() + if let date = achievement.earnedAt { + Text(date.formatted(date: .abbreviated, time: .omitted).uppercased()) + .font(.system(size: 14, weight: .bold)) + .tracking(2) + .foregroundStyle(theme.secondaryTextColor) + } + } + .padding(.bottom, 16) + + // Hairline + Rectangle().fill(theme.borderColor).frame(height: 1) + + Spacer().frame(height: 60) + + // Asymmetric: badge left, text right + HStack(alignment: .center, spacing: 40) { + SampleBadgeMinimal( + definition: achievement.definition, + size: 280 + ) + + VStack(alignment: .leading, spacing: 20) { + Text(achievement.definition.name) + .font(.system(size: 52, weight: .heavy)) + .foregroundStyle(theme.textColor) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + + Rectangle() + .fill(theme.accentColor) + .frame(width: 60, height: 3) + + Text(achievement.definition.description) + .font(.system(size: 22, weight: .light)) + .foregroundStyle(theme.secondaryTextColor) + .lineLimit(4) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + + // Bottom: thin rule + category label + Rectangle().fill(theme.borderColor).frame(height: 1) + + HStack { + Text(achievement.definition.category.displayName.uppercased()) + .font(.system(size: 14, weight: .bold)) + .tracking(4) + .foregroundStyle(theme.secondaryTextColor) + Spacer() + if let sport = achievement.definition.sport { + Text(sport.rawValue) + .font(.system(size: 14, weight: .bold)) + .tracking(4) + .foregroundStyle(theme.accentColor) + } + } + .padding(.top, 16) + .padding(.bottom, 40) + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height) + } +} + +private struct SampleB_Milestone: View { + let achievement: AchievementProgress + let theme: ShareTheme + private let gold = Color(hex: "FFD700") + + var body: some View { + ZStack { + ShareCardBackground(theme: theme, sports: achievement.sportSet) + + VStack(spacing: 0) { + HStack { + Text("MILESTONE") + .font(.system(size: 14, weight: .bold)) + .tracking(6) + .foregroundStyle(gold) + Spacer() + } + .padding(.bottom, 16) + + Rectangle().fill(gold.opacity(0.4)).frame(height: 1) + + Spacer() + + // Centered badge, larger + SampleBadgeMinimal(definition: achievement.definition, size: 400) + .overlay { + Circle() + .stroke(gold.opacity(0.5), lineWidth: 2) + .frame(width: 420, height: 420) + } + + Spacer().frame(height: 50) + + Text(achievement.definition.name) + .font(.system(size: 56, weight: .heavy)) + .foregroundStyle(theme.textColor) + .multilineTextAlignment(.center) + .lineLimit(3) + .minimumScaleFactor(0.65) + .padding(.horizontal, 40) + + Rectangle() + .fill(gold) + .frame(width: 80, height: 3) + .padding(.vertical, 20) + + Text(achievement.definition.description) + .font(.system(size: 22, weight: .light)) + .foregroundStyle(theme.secondaryTextColor) + .multilineTextAlignment(.center) + .padding(.horizontal, 80) + + Spacer() + + Rectangle().fill(gold.opacity(0.4)).frame(height: 1) + .padding(.bottom, 16) + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// MARK: - SAMPLE C: "Trophy Case" +// Museum display. Badge on a shelf with spotlight from above. Name on a +// plaque below. Feels like looking into a glass trophy cabinet. +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +private struct SampleC_Spotlight: View { + let achievement: AchievementProgress + let theme: ShareTheme + + var body: some View { + ZStack { + ShareCardBackground(theme: theme, sports: achievement.sportSet) + + // Spotlight cone from top + RadialGradient( + colors: [theme.accentColor.opacity(0.15), .clear], + center: UnitPoint(x: 0.5, y: 0.25), + startRadius: 10, + endRadius: 500 + ) + + VStack(spacing: 0) { + Spacer().frame(height: 80) + + // Top label + Text("UNLOCKED") + .font(.system(size: 18, weight: .black)) + .tracking(8) + .foregroundStyle(theme.accentColor) + + Spacer() + + // Badge on a "shelf" + VStack(spacing: 0) { + SampleBadgeAppIcon( + definition: achievement.definition, + size: 340 + ) + .shadow(color: .black.opacity(0.4), radius: 20, y: 15) + + // Shelf line + Rectangle() + .fill( + LinearGradient( + colors: [.clear, theme.textColor.opacity(0.3), .clear], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: 500, height: 2) + .padding(.top, 20) + + // Shadow under shelf + Ellipse() + .fill(.black.opacity(0.2)) + .frame(width: 300, height: 20) + .blur(radius: 8) + .offset(y: 4) + } + + Spacer().frame(height: 50) + + // "Plaque" with name + VStack(spacing: 12) { + Text(achievement.definition.name) + .font(.system(size: 44, weight: .bold)) + .foregroundStyle(theme.textColor) + .multilineTextAlignment(.center) + .lineLimit(3) + .minimumScaleFactor(0.65) + + Text(achievement.definition.description) + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(theme.secondaryTextColor) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 40) + .padding(.vertical, 28) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(theme.surfaceColor) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(theme.accentColor.opacity(0.3), lineWidth: 2) + ) + ) + .padding(.horizontal, 40) + + if let date = achievement.earnedAt { + Text(date.formatted(date: .long, time: .omitted)) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(theme.secondaryTextColor) + .padding(.top, 20) + } + + Spacer() + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height) + } +} + +private struct SampleC_Milestone: View { + let achievement: AchievementProgress + let theme: ShareTheme + private let gold = Color(hex: "FFD700") + private let goldDark = Color(hex: "B8860B") + + var body: some View { + ZStack { + ShareCardBackground(theme: theme, sports: achievement.sportSet) + + // Gold spotlight + RadialGradient( + colors: [gold.opacity(0.12), .clear], + center: UnitPoint(x: 0.5, y: 0.28), + startRadius: 10, + endRadius: 600 + ) + + VStack(spacing: 0) { + Spacer().frame(height: 60) + + Text("MILESTONE") + .font(.system(size: 22, weight: .black)) + .tracking(8) + .foregroundStyle(gold) + + Spacer() + + // Badge with gold shelf + VStack(spacing: 0) { + SampleBadgeAppIcon(definition: achievement.definition, size: 380) + .shadow(color: gold.opacity(0.3), radius: 30, y: 10) + .overlay { + RoundedRectangle(cornerRadius: 380 * 0.22) + .stroke(gold.opacity(0.6), lineWidth: 3) + .frame(width: 390, height: 390) + } + + // Gold shelf + Rectangle() + .fill( + LinearGradient(colors: [.clear, gold.opacity(0.5), .clear], + startPoint: .leading, endPoint: .trailing) + ) + .frame(width: 500, height: 3) + .padding(.top, 20) + } + + Spacer().frame(height: 50) + + // Gold plaque + VStack(spacing: 12) { + Text(achievement.definition.name) + .font(.system(size: 44, weight: .bold)) + .foregroundStyle(theme.textColor) + .multilineTextAlignment(.center) + .lineLimit(3) + .minimumScaleFactor(0.65) + + Text(achievement.definition.description) + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(theme.secondaryTextColor) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 40) + .padding(.vertical, 28) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(gold.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke( + LinearGradient(colors: [gold, goldDark], startPoint: .top, endPoint: .bottom), + lineWidth: 2 + ) + ) + ) + .padding(.horizontal, 40) + + Spacer() + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// MARK: - SAMPLE D: "Poster" +// Street-poster / hype-beast style. Achievement name is MASSIVE and fills +// the card. Badge overlaps the type. Diagonal accent stripe. Rotated text. +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +private struct SampleD_Spotlight: View { + let achievement: AchievementProgress + let theme: ShareTheme + + var body: some View { + ZStack { + ShareCardBackground(theme: theme, sports: achievement.sportSet) + + // Diagonal accent stripe + Rectangle() + .fill(theme.accentColor.opacity(0.15)) + .frame(width: 300, height: 3000) + .rotationEffect(.degrees(-25)) + .offset(x: -100) + + Rectangle() + .fill(theme.accentColor.opacity(0.08)) + .frame(width: 200, height: 3000) + .rotationEffect(.degrees(-25)) + .offset(x: 200) + + VStack(spacing: 0) { + // Top: rotated side label + HStack { + Text("UNLOCKED") + .font(.system(size: 14, weight: .black)) + .tracking(6) + .foregroundStyle(theme.accentColor) + .rotationEffect(.degrees(-90)) + .fixedSize() + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + if let sport = achievement.definition.sport { + Text(sport.rawValue) + .font(.system(size: 20, weight: .black)) + .tracking(3) + .foregroundStyle(theme.accentColor) + } + Text(achievement.definition.category.displayName.uppercased()) + .font(.system(size: 14, weight: .bold)) + .tracking(2) + .foregroundStyle(theme.secondaryTextColor) + } + } + .padding(.top, 20) + + Spacer() + + // MASSIVE name + ZStack { + Text(achievement.definition.name.uppercased()) + .font(.system(size: 90, weight: .black)) + .foregroundStyle(theme.textColor.opacity(0.08)) + .multilineTextAlignment(.center) + .lineLimit(4) + .minimumScaleFactor(0.4) + .padding(.horizontal, 20) + + // Badge floating on top + SampleBadgeDiamond( + definition: achievement.definition, + size: 320 + ) + .shadow(color: .black.opacity(0.4), radius: 16, y: 8) + } + + Spacer().frame(height: 30) + + // Name (readable size) + Text(achievement.definition.name) + .font(.system(size: 56, weight: .black)) + .foregroundStyle(theme.textColor) + .multilineTextAlignment(.center) + .lineLimit(3) + .minimumScaleFactor(0.6) + .padding(.horizontal, 40) + + Spacer().frame(height: 16) + + Text(achievement.definition.description) + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(theme.secondaryTextColor) + .multilineTextAlignment(.center) + .padding(.horizontal, 80) + + if let date = achievement.earnedAt { + Text(date.formatted(date: .abbreviated, time: .omitted).uppercased()) + .font(.system(size: 18, weight: .black)) + .tracking(3) + .foregroundStyle(theme.accentColor) + .padding(.top, 16) + } + + Spacer() + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height) + } +} + +private struct SampleD_Milestone: View { + let achievement: AchievementProgress + let theme: ShareTheme + private let gold = Color(hex: "FFD700") + + var body: some View { + ZStack { + ShareCardBackground(theme: theme, sports: achievement.sportSet) + + // Gold diagonal stripes + Rectangle() + .fill(gold.opacity(0.1)) + .frame(width: 300, height: 3000) + .rotationEffect(.degrees(-25)) + .offset(x: -100) + + Rectangle() + .fill(gold.opacity(0.06)) + .frame(width: 200, height: 3000) + .rotationEffect(.degrees(-25)) + .offset(x: 200) + + VStack(spacing: 0) { + Text("MILESTONE") + .font(.system(size: 20, weight: .black)) + .tracking(8) + .foregroundStyle(gold) + .padding(.top, 20) + + Spacer() + + ZStack { + Text(achievement.definition.name.uppercased()) + .font(.system(size: 90, weight: .black)) + .foregroundStyle(gold.opacity(0.06)) + .multilineTextAlignment(.center) + .lineLimit(4) + .minimumScaleFactor(0.4) + .padding(.horizontal, 20) + + SampleBadgeDiamond(definition: achievement.definition, size: 360) + .overlay { + // Gold diamond border + Rectangle() + .stroke(gold.opacity(0.5), lineWidth: 3) + .frame(width: 270, height: 270) + .rotationEffect(.degrees(45)) + } + .shadow(color: gold.opacity(0.3), radius: 20, y: 8) + } + + Spacer().frame(height: 30) + + Text(achievement.definition.name) + .font(.system(size: 52, weight: .black)) + .foregroundStyle(theme.textColor) + .multilineTextAlignment(.center) + .lineLimit(3) + .minimumScaleFactor(0.6) + .padding(.horizontal, 40) + + Spacer().frame(height: 16) + + Text(achievement.definition.description) + .font(.system(size: 22, weight: .medium)) + .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: - SAMPLE E: "Ticket Stub" +// Vintage game-day ticket. Notched edges, dashed tear line, compact info +// sections, serial number decoration. Feels like a collectible stub. +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +private struct SampleE_Spotlight: View { + let achievement: AchievementProgress + let theme: ShareTheme + + var body: some View { + ZStack { + ShareCardBackground(theme: theme, sports: achievement.sportSet) + + VStack(spacing: 0) { + Spacer().frame(height: 40) + + // TICKET — top portion + VStack(spacing: 16) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("SPORTSTIME ACHIEVEMENTS") + .font(.system(size: 14, weight: .black)) + .tracking(2) + .foregroundStyle(theme.secondaryTextColor) + Text("ADMIT ONE") + .font(.system(size: 28, weight: .black)) + .foregroundStyle(theme.textColor) + } + Spacer() + if let sport = achievement.definition.sport { + Text(sport.rawValue) + .font(.system(size: 36, weight: .black, design: .rounded)) + .foregroundStyle(theme.accentColor) + } + } + + // Dashed tear line with notches + HStack(spacing: 0) { + Circle() + .fill(theme.gradientColors.first ?? .black) + .frame(width: 30, height: 30) + .offset(x: -15) + + Rectangle() + .stroke(theme.textColor.opacity(0.2), style: StrokeStyle(lineWidth: 2, dash: [8, 6])) + .frame(height: 2) + + Circle() + .fill(theme.gradientColors.first ?? .black) + .frame(width: 30, height: 30) + .offset(x: 15) + } + } + .padding(28) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(theme.surfaceColor) + .overlay(RoundedRectangle(cornerRadius: 20).stroke(theme.borderColor, lineWidth: 1)) + ) + + Spacer().frame(height: 30) + + // Main content area + VStack(spacing: 24) { + SampleBadgeRoundedRect( + definition: achievement.definition, + size: 300 + ) + + Text(achievement.definition.name) + .font(.system(size: 48, weight: .black)) + .foregroundStyle(theme.textColor) + .multilineTextAlignment(.center) + .lineLimit(3) + .minimumScaleFactor(0.6) + + Text(achievement.definition.description) + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(theme.secondaryTextColor) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + + Spacer().frame(height: 30) + + // Bottom stub info + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("DATE") + .font(.system(size: 12, weight: .black)) + .tracking(2) + .foregroundStyle(theme.secondaryTextColor) + if let date = achievement.earnedAt { + Text(date.formatted(date: .abbreviated, time: .omitted)) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(theme.textColor) + } + } + Spacer() + VStack(alignment: .center, spacing: 4) { + Text("CATEGORY") + .font(.system(size: 12, weight: .black)) + .tracking(2) + .foregroundStyle(theme.secondaryTextColor) + Text(achievement.definition.category.displayName.uppercased()) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(theme.textColor) + } + Spacer() + VStack(alignment: .trailing, spacing: 4) { + Text("NO.") + .font(.system(size: 12, weight: .black)) + .tracking(2) + .foregroundStyle(theme.secondaryTextColor) + Text(String(achievement.definition.id.prefix(8)).uppercased()) + .font(.system(size: 16, weight: .bold, design: .monospaced)) + .foregroundStyle(theme.accentColor) + } + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(theme.surfaceColor) + .overlay(RoundedRectangle(cornerRadius: 16).stroke(theme.borderColor, lineWidth: 1)) + ) + + // Barcode decoration + HStack(spacing: 2) { + ForEach(0..<30, id: \.self) { i in + Rectangle() + .fill(theme.textColor.opacity(0.15)) + .frame(width: barWidth(for: i), height: 28) + } + } + .padding(.top, 12) + + Spacer() + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height) + } + + private func barWidth(for index: Int) -> CGFloat { + let widths: [CGFloat] = [3, 2, 4, 2, 3, 5, 2, 3, 2, 4] + return widths[index % widths.count] + } +} + +private struct SampleE_Milestone: View { + let achievement: AchievementProgress + let theme: ShareTheme + private let gold = Color(hex: "FFD700") + + var body: some View { + ZStack { + ShareCardBackground(theme: theme, sports: achievement.sportSet) + + VStack(spacing: 0) { + Spacer().frame(height: 40) + + // Gold ticket header + VStack(spacing: 16) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("MILESTONE ACHIEVEMENT") + .font(.system(size: 14, weight: .black)) + .tracking(2) + .foregroundStyle(gold) + Text("VIP ACCESS") + .font(.system(size: 28, weight: .black)) + .foregroundStyle(theme.textColor) + } + Spacer() + Image(systemName: "trophy.fill") + .font(.system(size: 32)) + .foregroundStyle(gold) + } + + HStack(spacing: 0) { + Circle() + .fill(theme.gradientColors.first ?? .black) + .frame(width: 30, height: 30) + .offset(x: -15) + Rectangle() + .stroke(gold.opacity(0.3), style: StrokeStyle(lineWidth: 2, dash: [8, 6])) + .frame(height: 2) + Circle() + .fill(theme.gradientColors.first ?? .black) + .frame(width: 30, height: 30) + .offset(x: 15) + } + } + .padding(28) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(theme.surfaceColor) + .overlay(RoundedRectangle(cornerRadius: 20).stroke(gold.opacity(0.4), lineWidth: 2)) + ) + + Spacer().frame(height: 30) + + VStack(spacing: 24) { + SampleBadgeRoundedRect(definition: achievement.definition, size: 320) + .overlay { + RoundedRectangle(cornerRadius: 320 * 0.22) + .stroke(gold.opacity(0.5), lineWidth: 3) + .frame(width: 330, height: 330) + } + + Text(achievement.definition.name) + .font(.system(size: 48, weight: .black)) + .foregroundStyle(theme.textColor) + .multilineTextAlignment(.center) + .lineLimit(3) + .minimumScaleFactor(0.6) + + Text(achievement.definition.description) + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(theme.secondaryTextColor) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + + Spacer() + + ShareCardFooter(theme: theme) + } + .padding(ShareCardDimensions.padding) + } + .frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height) + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// MARK: - Badge Variants +// Each sample gets its own badge shape for maximum variety. +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +// A: Classic circle with thick gradient ring +private struct SampleBadgeCircle: View { + let definition: AchievementDefinition + let size: CGFloat + + var body: some View { + ZStack { + Circle() + .stroke( + LinearGradient( + colors: [definition.iconColor, definition.iconColor.opacity(0.4)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: size * 0.04 + ) + .frame(width: size * 0.92, height: size * 0.92) + + Circle() + .fill( + RadialGradient( + colors: [definition.iconColor.opacity(0.25), definition.iconColor.opacity(0.05)], + center: .center, + startRadius: 0, + endRadius: size * 0.45 + ) + ) + .frame(width: size * 0.84, height: size * 0.84) + + Image(systemName: definition.iconName) + .font(.system(size: size * 0.4, weight: .bold)) + .foregroundStyle(definition.iconColor) + } + .frame(width: size, height: size) + } +} + +// B: Minimal — just color fill, no border, very clean +private struct SampleBadgeMinimal: View { + let definition: AchievementDefinition + let size: CGFloat + + var body: some View { + ZStack { + Circle() + .fill(definition.iconColor.opacity(0.12)) + .frame(width: size, height: size) + + Image(systemName: definition.iconName) + .font(.system(size: size * 0.42, weight: .light)) + .foregroundStyle(definition.iconColor) + } + } +} + +// C: iOS app icon style — rounded square with shadow +private struct SampleBadgeAppIcon: View { + let definition: AchievementDefinition + let size: CGFloat + + var body: some View { + ZStack { + 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) + + RoundedRectangle(cornerRadius: size * 0.22) + .stroke(definition.iconColor.opacity(0.5), lineWidth: size * 0.02) + .frame(width: size, height: size) + + Image(systemName: definition.iconName) + .font(.system(size: size * 0.42, weight: .semibold)) + .foregroundStyle(definition.iconColor) + } + .shadow(color: .black.opacity(0.2), radius: 8, y: 4) + } +} + +// D: Diamond — rotated square +private struct SampleBadgeDiamond: View { + let definition: AchievementDefinition + let size: CGFloat + + var body: some View { + ZStack { + Rectangle() + .fill(definition.iconColor.opacity(0.15)) + .frame(width: size * 0.65, height: size * 0.65) + .rotationEffect(.degrees(45)) + + Rectangle() + .stroke(definition.iconColor.opacity(0.6), lineWidth: size * 0.02) + .frame(width: size * 0.65, height: size * 0.65) + .rotationEffect(.degrees(45)) + + Image(systemName: definition.iconName) + .font(.system(size: size * 0.35, weight: .bold)) + .foregroundStyle(definition.iconColor) + } + .frame(width: size, height: size) + } +} + +// E: Rounded rectangle with inner circle — ticket/stamp look +private struct SampleBadgeRoundedRect: View { + let definition: AchievementDefinition + let size: CGFloat + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: size * 0.18) + .fill(definition.iconColor.opacity(0.1)) + .frame(width: size, height: size) + + RoundedRectangle(cornerRadius: size * 0.18) + .stroke(definition.iconColor.opacity(0.4), lineWidth: size * 0.02) + .frame(width: size, height: size) + + // Inner stamp circle + Circle() + .stroke(definition.iconColor.opacity(0.2), lineWidth: size * 0.01) + .frame(width: size * 0.7, height: size * 0.7) + + Image(systemName: definition.iconName) + .font(.system(size: size * 0.38, weight: .bold)) + .foregroundStyle(definition.iconColor) + } + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// MARK: - Support Shapes +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +private struct GridPatternA: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let spacing: CGFloat = 40 + var x: CGFloat = 0 + while x <= rect.width { + path.move(to: CGPoint(x: x, y: 0)) + path.addLine(to: CGPoint(x: x, y: rect.height)) + x += spacing + } + var y: CGFloat = 0 + while y <= rect.height { + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: rect.width, y: y)) + y += spacing + } + return path + } +} + +// Helper for sport set +private extension AchievementProgress { + var sportSet: Set { + if let sport = definition.sport { + return [sport] + } + return [] + } +} + +#endif diff --git a/SportsTime/Export/Sharing/ProgressCardGenerator.swift b/SportsTime/Export/Sharing/ProgressCardGenerator.swift index 6321311..232c023 100644 --- a/SportsTime/Export/Sharing/ProgressCardGenerator.swift +++ b/SportsTime/Export/Sharing/ProgressCardGenerator.swift @@ -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 } } diff --git a/SportsTime/Export/Sharing/ProgressDesignSamples.swift b/SportsTime/Export/Sharing/ProgressDesignSamples.swift new file mode 100644 index 0000000..c3bd91d --- /dev/null +++ b/SportsTime/Export/Sharing/ProgressDesignSamples.swift @@ -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 diff --git a/SportsTime/Export/Sharing/ShareCardComponents.swift b/SportsTime/Export/Sharing/ShareCardComponents.swift index b7a5d32..dbd7303 100644 --- a/SportsTime/Export/Sharing/ShareCardComponents.swift +++ b/SportsTime/Export/Sharing/ShareCardComponents.swift @@ -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? = 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.. 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 + ) } } diff --git a/SportsTime/Export/Sharing/ShareCardSportBackground.swift b/SportsTime/Export/Sharing/ShareCardSportBackground.swift index 94ef38c..28b0030 100644 --- a/SportsTime/Export/Sharing/ShareCardSportBackground.swift +++ b/SportsTime/Export/Sharing/ShareCardSportBackground.swift @@ -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 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.. 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) } diff --git a/SportsTime/Export/Sharing/ShareService.swift b/SportsTime/Export/Sharing/ShareService.swift index e3326df..428835c 100644 --- a/SportsTime/Export/Sharing/ShareService.swift +++ b/SportsTime/Export/Sharing/ShareService.swift @@ -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 diff --git a/SportsTime/Export/Sharing/ShareableContent.swift b/SportsTime/Export/Sharing/ShareableContent.swift index 93259a0..c34edb3 100644 --- a/SportsTime/Export/Sharing/ShareableContent.swift +++ b/SportsTime/Export/Sharing/ShareableContent.swift @@ -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 diff --git a/SportsTime/Export/Sharing/TripCardGenerator.swift b/SportsTime/Export/Sharing/TripCardGenerator.swift index 3cc29cd..be6ebe0 100644 --- a/SportsTime/Export/Sharing/TripCardGenerator.swift +++ b/SportsTime/Export/Sharing/TripCardGenerator.swift @@ -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 } } diff --git a/SportsTime/Export/Sharing/TripDesignSamples.swift b/SportsTime/Export/Sharing/TripDesignSamples.swift new file mode 100644 index 0000000..d20f909 --- /dev/null +++ b/SportsTime/Export/Sharing/TripDesignSamples.swift @@ -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 diff --git a/SportsTime/Features/Progress/Views/AchievementsListView.swift b/SportsTime/Features/Progress/Views/AchievementsListView.swift index 6c3b5aa..3ceceaa 100644 --- a/SportsTime/Features/Progress/Views/AchievementsListView.swift +++ b/SportsTime/Features/Progress/Views/AchievementsListView.swift @@ -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 { diff --git a/SportsTime/Features/Settings/DebugShareExporter.swift b/SportsTime/Features/Settings/DebugShareExporter.swift index 2650f2c..4d53133 100644 --- a/SportsTime/Features/Settings/DebugShareExporter.swift +++ b/SportsTime/Features/Settings/DebugShareExporter.swift @@ -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" diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index 83fe6a1..3f38143 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -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: { diff --git a/SportsTimeTests/Export/ShareableContentTests.swift b/SportsTimeTests/Export/ShareableContentTests.swift index 8beb9db..3dbc45f 100644 --- a/SportsTimeTests/Export/ShareableContentTests.swift +++ b/SportsTimeTests/Export/ShareableContentTests.swift @@ -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) } }