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