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:
546
SportsTime/Export/Sharing/ProgressDesignSamples.swift
Normal file
546
SportsTime/Export/Sharing/ProgressDesignSamples.swift
Normal file
@@ -0,0 +1,546 @@
|
||||
//
|
||||
// ProgressDesignSamples.swift
|
||||
// SportsTime
|
||||
//
|
||||
// 5 design explorations for stadium progress cards.
|
||||
// All follow the unified design language: solid bg, ghost text, no panels, app icon footer.
|
||||
//
|
||||
|
||||
#if DEBUG
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - Sample A: "Ring Center"
|
||||
// Giant progress ring centered. Percentage inside the ring.
|
||||
// Sport name above, visited/remaining/trips stats below as plain text.
|
||||
|
||||
struct ProgressSampleA: View {
|
||||
let progress: LeagueProgress
|
||||
let tripCount: Int
|
||||
let theme: ShareTheme
|
||||
let mapSnapshot: UIImage?
|
||||
|
||||
private let gold = Color(hex: "FFD700")
|
||||
|
||||
private var isComplete: Bool { progress.completionPercentage >= 100 }
|
||||
private var accent: Color { isComplete ? gold : theme.accentColor }
|
||||
private var remaining: Int { max(0, progress.totalStadiums - progress.visitedStadiums) }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(theme.gradientColors.first ?? .black)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Ghost percentage
|
||||
Text("\(Int(progress.completionPercentage))%")
|
||||
.font(.system(size: 300, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.textColor.opacity(0.05))
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Text(progress.sport.displayName.uppercased())
|
||||
.font(.system(size: 20, weight: .black))
|
||||
.tracking(8)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
.padding(.top, 20)
|
||||
|
||||
Text("STADIUM QUEST")
|
||||
.font(.system(size: 44, weight: .black))
|
||||
.foregroundStyle(theme.textColor)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Ring
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(theme.textColor.opacity(0.1), lineWidth: 24)
|
||||
.frame(width: 400, height: 400)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress.completionPercentage / 100)
|
||||
.stroke(accent, style: StrokeStyle(lineWidth: 24, lineCap: .round))
|
||||
.frame(width: 400, height: 400)
|
||||
.rotationEffect(.degrees(-90))
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("\(progress.visitedStadiums)")
|
||||
.font(.system(size: 108, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.textColor)
|
||||
Text("of \(progress.totalStadiums)")
|
||||
.font(.system(size: 28, weight: .medium))
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer().frame(height: 50)
|
||||
|
||||
// Stats as plain text
|
||||
HStack(spacing: 50) {
|
||||
statItem(value: "\(progress.visitedStadiums)", label: "VISITED")
|
||||
statItem(value: "\(remaining)", label: "TO GO")
|
||||
statItem(value: "\(tripCount)", label: "TRIPS")
|
||||
}
|
||||
|
||||
Spacer().frame(height: 40)
|
||||
|
||||
// Map
|
||||
if let mapSnapshot {
|
||||
Image(uiImage: mapSnapshot)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.frame(height: 350)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ProgressSampleAppFooter(theme: theme)
|
||||
}
|
||||
.padding(ShareCardDimensions.padding)
|
||||
}
|
||||
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
|
||||
}
|
||||
|
||||
private func statItem(value: String, label: String) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
Text(value)
|
||||
.font(.system(size: 44, weight: .black, design: .rounded))
|
||||
.foregroundStyle(accent)
|
||||
Text(label)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.tracking(2)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample B: "Big Number"
|
||||
// Massive percentage dominates the card. Thin horizontal progress bar.
|
||||
// Sport icon + name at top. Minimal stats.
|
||||
|
||||
struct ProgressSampleB: View {
|
||||
let progress: LeagueProgress
|
||||
let tripCount: Int
|
||||
let theme: ShareTheme
|
||||
let mapSnapshot: UIImage?
|
||||
|
||||
private let gold = Color(hex: "FFD700")
|
||||
|
||||
private var isComplete: Bool { progress.completionPercentage >= 100 }
|
||||
private var accent: Color { isComplete ? gold : theme.accentColor }
|
||||
private var remaining: Int { max(0, progress.totalStadiums - progress.visitedStadiums) }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(theme.gradientColors.first ?? .black)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Ghost sport name
|
||||
Text(progress.sport.displayName.uppercased())
|
||||
.font(.system(size: 140, weight: .black))
|
||||
.foregroundStyle(theme.textColor.opacity(0.04))
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Sport icon + name
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: progress.sport.iconName)
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(accent)
|
||||
Text(progress.sport.displayName.uppercased())
|
||||
.font(.system(size: 20, weight: .black))
|
||||
.tracking(6)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
.padding(.top, 20)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Massive percentage
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(Int(progress.completionPercentage))")
|
||||
.font(.system(size: 200, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.textColor)
|
||||
.minimumScaleFactor(0.5)
|
||||
Text("%")
|
||||
.font(.system(size: 72, weight: .black, design: .rounded))
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
// Thin progress bar
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(theme.textColor.opacity(0.1))
|
||||
.frame(height: 8)
|
||||
Capsule()
|
||||
.fill(accent)
|
||||
.frame(width: geo.size.width * progress.completionPercentage / 100, height: 8)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
.padding(.horizontal, 60)
|
||||
|
||||
Spacer().frame(height: 24)
|
||||
|
||||
Text("\(progress.visitedStadiums) of \(progress.totalStadiums) stadiums")
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
|
||||
Spacer().frame(height: 40)
|
||||
|
||||
// Map
|
||||
if let mapSnapshot {
|
||||
Image(uiImage: mapSnapshot)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.frame(height: 380)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ProgressSampleAppFooter(theme: theme)
|
||||
}
|
||||
.padding(ShareCardDimensions.padding)
|
||||
}
|
||||
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample C: "Fraction Hero"
|
||||
// "15 of 30" as the hero element, big and centered.
|
||||
// Small ring to the side. Map below. Clean and focused.
|
||||
|
||||
struct ProgressSampleC: View {
|
||||
let progress: LeagueProgress
|
||||
let tripCount: Int
|
||||
let theme: ShareTheme
|
||||
let mapSnapshot: UIImage?
|
||||
|
||||
private let gold = Color(hex: "FFD700")
|
||||
|
||||
private var isComplete: Bool { progress.completionPercentage >= 100 }
|
||||
private var accent: Color { isComplete ? gold : theme.accentColor }
|
||||
private var remaining: Int { max(0, progress.totalStadiums - progress.visitedStadiums) }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(theme.gradientColors.first ?? .black)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Ghost visited count
|
||||
Text("\(progress.visitedStadiums)")
|
||||
.font(.system(size: 400, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.textColor.opacity(0.04))
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Text("STADIUM QUEST")
|
||||
.font(.system(size: 18, weight: .black))
|
||||
.tracking(6)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
.padding(.top, 20)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Hero fraction
|
||||
VStack(spacing: 8) {
|
||||
Text("\(progress.visitedStadiums)")
|
||||
.font(.system(size: 160, weight: .black, design: .rounded))
|
||||
.foregroundStyle(accent)
|
||||
|
||||
Rectangle()
|
||||
.fill(theme.textColor.opacity(0.2))
|
||||
.frame(width: 200, height: 3)
|
||||
|
||||
Text("\(progress.totalStadiums)")
|
||||
.font(.system(size: 80, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.textColor.opacity(0.4))
|
||||
}
|
||||
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
Text(progress.sport.displayName.uppercased() + " STADIUMS")
|
||||
.font(.system(size: 22, weight: .black))
|
||||
.tracking(4)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
|
||||
if isComplete {
|
||||
Text("COMPLETE")
|
||||
.font(.system(size: 20, weight: .black))
|
||||
.tracking(6)
|
||||
.foregroundStyle(gold)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 40)
|
||||
|
||||
// Map
|
||||
if let mapSnapshot {
|
||||
Image(uiImage: mapSnapshot)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.frame(height: 380)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ProgressSampleAppFooter(theme: theme)
|
||||
}
|
||||
.padding(ShareCardDimensions.padding)
|
||||
}
|
||||
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample D: "Map Hero"
|
||||
// Map takes center stage, large. Progress info above and below.
|
||||
// Sport icon badge at top.
|
||||
|
||||
struct ProgressSampleD: View {
|
||||
let progress: LeagueProgress
|
||||
let tripCount: Int
|
||||
let theme: ShareTheme
|
||||
let mapSnapshot: UIImage?
|
||||
|
||||
private let gold = Color(hex: "FFD700")
|
||||
|
||||
private var isComplete: Bool { progress.completionPercentage >= 100 }
|
||||
private var accent: Color { isComplete ? gold : theme.accentColor }
|
||||
private var remaining: Int { max(0, progress.totalStadiums - progress.visitedStadiums) }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(theme.gradientColors.first ?? .black)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Ghost sport name
|
||||
Text(progress.sport.rawValue)
|
||||
.font(.system(size: 200, weight: .black))
|
||||
.foregroundStyle(theme.textColor.opacity(0.04))
|
||||
.rotationEffect(.degrees(-15))
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Sport icon badge + title
|
||||
Image(systemName: progress.sport.iconName)
|
||||
.font(.system(size: 44, weight: .bold))
|
||||
.foregroundStyle(accent)
|
||||
.padding(.top, 20)
|
||||
|
||||
Text(progress.sport.displayName.uppercased())
|
||||
.font(.system(size: 20, weight: .black))
|
||||
.tracking(6)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
.padding(.top, 10)
|
||||
|
||||
// Percentage
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(Int(progress.completionPercentage))")
|
||||
.font(.system(size: 80, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.textColor)
|
||||
Text("% COMPLETE")
|
||||
.font(.system(size: 22, weight: .black))
|
||||
.tracking(2)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
|
||||
Spacer().frame(height: 30)
|
||||
|
||||
// Large map
|
||||
if let mapSnapshot {
|
||||
Image(uiImage: mapSnapshot)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
.padding(.horizontal, 16)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.fill(theme.textColor.opacity(0.06))
|
||||
.frame(height: 600)
|
||||
.overlay {
|
||||
Image(systemName: "map")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 30)
|
||||
|
||||
// Stats below map
|
||||
HStack(spacing: 50) {
|
||||
VStack(spacing: 4) {
|
||||
Text("\(progress.visitedStadiums)")
|
||||
.font(.system(size: 40, weight: .black, design: .rounded))
|
||||
.foregroundStyle(accent)
|
||||
Text("VISITED")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.tracking(2)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
VStack(spacing: 4) {
|
||||
Text("\(remaining)")
|
||||
.font(.system(size: 40, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.textColor)
|
||||
Text("TO GO")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.tracking(2)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
VStack(spacing: 4) {
|
||||
Text("\(tripCount)")
|
||||
.font(.system(size: 40, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.textColor)
|
||||
Text("TRIPS")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.tracking(2)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ProgressSampleAppFooter(theme: theme)
|
||||
}
|
||||
.padding(ShareCardDimensions.padding)
|
||||
}
|
||||
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sample E: "Countdown"
|
||||
// Focus on what's LEFT: "15 TO GO" is the hero.
|
||||
// Visited shown smaller. Optimistic, forward-looking tone.
|
||||
|
||||
struct ProgressSampleE: View {
|
||||
let progress: LeagueProgress
|
||||
let tripCount: Int
|
||||
let theme: ShareTheme
|
||||
let mapSnapshot: UIImage?
|
||||
|
||||
private let gold = Color(hex: "FFD700")
|
||||
|
||||
private var isComplete: Bool { progress.completionPercentage >= 100 }
|
||||
private var accent: Color { isComplete ? gold : theme.accentColor }
|
||||
private var remaining: Int { max(0, progress.totalStadiums - progress.visitedStadiums) }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
(theme.gradientColors.first ?? .black)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Ghost remaining number
|
||||
Text("\(remaining)")
|
||||
.font(.system(size: 400, weight: .black, design: .rounded))
|
||||
.foregroundStyle(accent.opacity(0.06))
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: progress.sport.iconName)
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(accent)
|
||||
Text(progress.sport.displayName.uppercased())
|
||||
.font(.system(size: 18, weight: .black))
|
||||
.tracking(4)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
}
|
||||
.padding(.top, 20)
|
||||
|
||||
Spacer()
|
||||
|
||||
if isComplete {
|
||||
// Complete state
|
||||
Text("ALL")
|
||||
.font(.system(size: 48, weight: .black))
|
||||
.tracking(8)
|
||||
.foregroundStyle(gold)
|
||||
|
||||
Text("\(progress.totalStadiums)")
|
||||
.font(.system(size: 160, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.textColor)
|
||||
|
||||
Text("STADIUMS VISITED")
|
||||
.font(.system(size: 24, weight: .black))
|
||||
.tracking(4)
|
||||
.foregroundStyle(gold)
|
||||
} else {
|
||||
// Countdown state
|
||||
Text("\(remaining)")
|
||||
.font(.system(size: 180, weight: .black, design: .rounded))
|
||||
.foregroundStyle(theme.textColor)
|
||||
|
||||
Text("STADIUMS TO GO")
|
||||
.font(.system(size: 24, weight: .black))
|
||||
.tracking(4)
|
||||
.foregroundStyle(theme.secondaryTextColor)
|
||||
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
Text("\(progress.visitedStadiums) of \(progress.totalStadiums) visited")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
|
||||
Spacer().frame(height: 40)
|
||||
|
||||
// Map
|
||||
if let mapSnapshot {
|
||||
Image(uiImage: mapSnapshot)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.frame(height: 380)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ProgressSampleAppFooter(theme: theme)
|
||||
}
|
||||
.padding(ShareCardDimensions.padding)
|
||||
}
|
||||
.frame(width: ShareCardDimensions.cardSize.width, height: ShareCardDimensions.cardSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared Footer
|
||||
|
||||
private struct ProgressSampleAppFooter: View {
|
||||
let theme: ShareTheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
if let icon = Self.loadAppIcon() {
|
||||
Image(uiImage: icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 56, height: 56)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 13))
|
||||
}
|
||||
|
||||
Text("SportsTime")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(theme.textColor.opacity(0.5))
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
private static func loadAppIcon() -> UIImage? {
|
||||
if let icons = Bundle.main.infoDictionary?["CFBundleIcons"] as? [String: Any],
|
||||
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
|
||||
let files = primary["CFBundleIconFiles"] as? [String],
|
||||
let name = files.last {
|
||||
return UIImage(named: name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user