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>
222 lines
6.1 KiB
Swift
222 lines
6.1 KiB
Swift
//
|
|
// ShareableContent.swift
|
|
// SportsTime
|
|
//
|
|
// Protocol for shareable content and theme definitions.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
// MARK: - Shareable Content Protocol
|
|
|
|
protocol ShareableContent {
|
|
var cardType: ShareCardType { get }
|
|
func render(theme: ShareTheme) async throws -> UIImage
|
|
}
|
|
|
|
// MARK: - Card Types
|
|
|
|
enum ShareCardType: String, CaseIterable {
|
|
case tripSummary
|
|
case achievementSpotlight
|
|
case achievementCollection
|
|
case stadiumProgress
|
|
}
|
|
|
|
// MARK: - Share Theme
|
|
|
|
struct ShareTheme: Identifiable, Hashable {
|
|
let id: String
|
|
let name: String
|
|
let gradientColors: [Color]
|
|
let accentColor: Color
|
|
let textColor: Color
|
|
let secondaryTextColor: Color
|
|
let useDarkMap: Bool
|
|
|
|
// MARK: - Preset Themes
|
|
|
|
static let dark = ShareTheme(
|
|
id: "dark",
|
|
name: "Dark",
|
|
gradientColors: [Color(hex: "1A1A2E"), Color(hex: "16213E")],
|
|
accentColor: Color(hex: "FF6B35"),
|
|
textColor: .white,
|
|
secondaryTextColor: Color(hex: "B8B8D1"),
|
|
useDarkMap: true
|
|
)
|
|
|
|
static let light = ShareTheme(
|
|
id: "light",
|
|
name: "Light",
|
|
gradientColors: [.white, Color(hex: "F5F5F5")],
|
|
accentColor: Color(hex: "FF6B35"),
|
|
textColor: Color(hex: "1A1A2E"),
|
|
secondaryTextColor: Color(hex: "666666"),
|
|
useDarkMap: false
|
|
)
|
|
|
|
static let midnight = ShareTheme(
|
|
id: "midnight",
|
|
name: "Midnight",
|
|
gradientColors: [Color(hex: "0D1B2A"), Color(hex: "1B263B")],
|
|
accentColor: Color(hex: "00D4FF"),
|
|
textColor: .white,
|
|
secondaryTextColor: Color(hex: "A0AEC0"),
|
|
useDarkMap: true
|
|
)
|
|
|
|
static let forest = ShareTheme(
|
|
id: "forest",
|
|
name: "Forest",
|
|
gradientColors: [Color(hex: "1B4332"), Color(hex: "2D6A4F")],
|
|
accentColor: Color(hex: "95D5B2"),
|
|
textColor: .white,
|
|
secondaryTextColor: Color(hex: "B7E4C7"),
|
|
useDarkMap: false
|
|
)
|
|
|
|
static let sunset = ShareTheme(
|
|
id: "sunset",
|
|
name: "Sunset",
|
|
gradientColors: [Color(hex: "FF6B35"), Color(hex: "F7931E")],
|
|
accentColor: .white,
|
|
textColor: .white,
|
|
secondaryTextColor: Color(hex: "FFE5D9"),
|
|
useDarkMap: false
|
|
)
|
|
|
|
static let berry = ShareTheme(
|
|
id: "berry",
|
|
name: "Berry",
|
|
gradientColors: [Color(hex: "4A0E4E"), Color(hex: "81267E")],
|
|
accentColor: Color(hex: "FF85A1"),
|
|
textColor: .white,
|
|
secondaryTextColor: Color(hex: "E0B0FF"),
|
|
useDarkMap: true
|
|
)
|
|
|
|
static let ocean = ShareTheme(
|
|
id: "ocean",
|
|
name: "Ocean",
|
|
gradientColors: [Color(hex: "023E8A"), Color(hex: "0077B6")],
|
|
accentColor: Color(hex: "90E0EF"),
|
|
textColor: .white,
|
|
secondaryTextColor: Color(hex: "CAF0F8"),
|
|
useDarkMap: true
|
|
)
|
|
|
|
static let slate = ShareTheme(
|
|
id: "slate",
|
|
name: "Slate",
|
|
gradientColors: [Color(hex: "2B2D42"), Color(hex: "3D405B")],
|
|
accentColor: Color(hex: "F4A261"),
|
|
textColor: Color(hex: "EDF2F4"),
|
|
secondaryTextColor: Color(hex: "8D99AE"),
|
|
useDarkMap: true
|
|
)
|
|
|
|
static let all: [ShareTheme] = [.dark, .light, .midnight, .forest, .sunset, .berry, .ocean, .slate]
|
|
|
|
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
|
|
|
|
enum ShareError: Error, LocalizedError {
|
|
case renderingFailed
|
|
case mapSnapshotFailed
|
|
case instagramNotInstalled
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .renderingFailed:
|
|
return "Failed to render share card"
|
|
case .mapSnapshotFailed:
|
|
return "Failed to generate map snapshot"
|
|
case .instagramNotInstalled:
|
|
return "Instagram is not installed"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Card Dimensions
|
|
|
|
enum ShareCardDimensions {
|
|
static let cardSize = CGSize(width: 1080, height: 1920)
|
|
static let mapSnapshotSize = CGSize(width: 960, height: 480) // Must fit within cardSize.width - 2*padding
|
|
static let routeMapSize = CGSize(width: 960, height: 576) // Must fit within cardSize.width - 2*padding
|
|
static let padding: CGFloat = 60
|
|
static let headerHeight: CGFloat = 120
|
|
static let footerHeight: CGFloat = 100
|
|
}
|