Redesign trip, progress, and achievement share cards with premium sports-media aesthetic. Remove unused milestone/context achievement card types (only used in debug exporter). Fix gold text unreadable in light mode. Fix sport selector to only show stroke on selected sport. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1204 lines
47 KiB
Swift
1204 lines
47 KiB
Swift
//
|
|
// 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<Sport> {
|
|
if let sport = definition.sport {
|
|
return [sport]
|
|
}
|
|
return []
|
|
}
|
|
}
|
|
|
|
#endif
|