Files
Sportstime/SportsTime/Core/Theme/ViewModifiers.swift
Trey t d63d311cab feat: add WCAG AA accessibility app-wide, fix CloudKit container config, remove debug logs
- Add VoiceOver labels, hints, and element grouping across all 60+ views
- Add Reduce Motion support (Theme.Animation.prefersReducedMotion) to all animations
- Replace fixed font sizes with semantic Dynamic Type styles
- Hide decorative elements from VoiceOver with .accessibilityHidden(true)
- Add .minimumHitTarget() modifier ensuring 44pt touch targets
- Add AccessibilityAnnouncer utility for VoiceOver announcements
- Improve color contrast values in Theme.swift for WCAG AA compliance
- Extract CloudKitContainerConfig for explicit container identity
- Remove PostHog debug console log from AnalyticsManager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:27:23 -06:00

271 lines
7.4 KiB
Swift

//
// ViewModifiers.swift
// SportsTime
//
// Reusable view modifiers for consistent styling across the app.
//
import SwiftUI
// MARK: - Card Style Modifier
struct CardStyle: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
var cornerRadius: CGFloat = Theme.CornerRadius.large
var padding: CGFloat = Theme.Spacing.lg
func body(content: Content) -> some View {
content
.padding(padding)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.overlay {
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5)
}
}
extension View {
func cardStyle(cornerRadius: CGFloat = Theme.CornerRadius.large, padding: CGFloat = Theme.Spacing.lg) -> some View {
modifier(CardStyle(cornerRadius: cornerRadius, padding: padding))
}
}
// MARK: - Glow Effect Modifier
struct GlowEffect: ViewModifier {
var color: Color = Theme.warmOrange
var radius: CGFloat = 8
func body(content: Content) -> some View {
content
.shadow(color: color.opacity(0.5), radius: radius / 2, y: 0)
.shadow(color: color.opacity(0.3), radius: radius, y: 0)
}
}
extension View {
func glowEffect(color: Color = Theme.warmOrange, radius: CGFloat = 8) -> some View {
modifier(GlowEffect(color: color, radius: radius))
}
}
// MARK: - Pressable Button Style
struct PressableButtonStyle: ButtonStyle {
var scale: CGFloat = 0.96
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? scale : 1.0)
.animation(
Theme.Animation.prefersReducedMotion ? nil : Theme.Animation.spring,
value: configuration.isPressed
)
}
}
extension View {
func pressableStyle(scale: CGFloat = 0.96) -> some View {
buttonStyle(PressableButtonStyle(scale: scale))
}
}
// MARK: - Minimum Hit Target Modifier
private struct MinimumHitTargetModifier: ViewModifier {
let size: CGFloat
func body(content: Content) -> some View {
content
.frame(minWidth: size, minHeight: size, alignment: .center)
.contentShape(Rectangle())
}
}
extension View {
/// Ensures interactive elements meet the recommended 44x44pt touch area.
func minimumHitTarget(_ size: CGFloat = 44) -> some View {
modifier(MinimumHitTargetModifier(size: size))
}
}
// MARK: - Accessibility Announcements
enum AccessibilityAnnouncer {
static func announce(_ message: String) {
guard !message.isEmpty else { return }
DispatchQueue.main.async {
UIAccessibility.post(notification: .announcement, argument: message)
}
}
}
// MARK: - Shimmer Effect Modifier
struct ShimmerEffect: ViewModifier {
@State private var phase: CGFloat = 0
func body(content: Content) -> some View {
content
.overlay {
if !Theme.Animation.prefersReducedMotion {
GeometryReader { geo in
LinearGradient(
colors: [
.clear,
Color.white.opacity(0.3),
.clear
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: geo.size.width * 2)
.offset(x: -geo.size.width + (geo.size.width * 2 * phase))
}
.mask(content)
}
}
.onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
}
extension View {
func shimmer() -> some View {
modifier(ShimmerEffect())
}
}
// MARK: - Staggered Animation Modifier
struct StaggeredAnimation: ViewModifier {
var index: Int
var delay: Double = Theme.Animation.staggerDelay
@State private var appeared = false
func body(content: Content) -> some View {
content
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 20)
.onAppear {
if Theme.Animation.prefersReducedMotion {
appeared = true
} else {
withAnimation(Theme.Animation.spring.delay(Double(index) * delay)) {
appeared = true
}
}
}
}
}
extension View {
func staggeredAnimation(index: Int, delay: Double = Theme.Animation.staggerDelay) -> some View {
modifier(StaggeredAnimation(index: index, delay: delay))
}
}
// MARK: - Badge Style Modifier
struct BadgeStyle: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
var color: Color = Theme.warmOrange
var filled: Bool = true
func body(content: Content) -> some View {
content
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(filled ? color : color.opacity(0.2))
.foregroundStyle(filled ? .white : color)
.clipShape(Capsule())
}
}
extension View {
func badgeStyle(color: Color = Theme.warmOrange, filled: Bool = true) -> some View {
modifier(BadgeStyle(color: color, filled: filled))
}
}
// MARK: - Section Header Style
struct SectionHeaderStyle: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
}
extension View {
func sectionHeaderStyle() -> some View {
modifier(SectionHeaderStyle())
}
}
// MARK: - Themed Background Modifier
struct ThemedBackground: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content
.background {
if DesignStyleManager.shared.animationsEnabled && !Theme.Animation.prefersReducedMotion {
AnimatedSportsBackground()
.ignoresSafeArea()
} else {
Theme.backgroundGradient(colorScheme)
.ignoresSafeArea()
}
}
}
}
extension View {
func themedBackground() -> some View {
modifier(ThemedBackground())
}
}
// MARK: - Sport Color Bar
struct SportColorBar: View {
let sport: Sport
var body: some View {
RoundedRectangle(cornerRadius: 2)
.fill(sport.themeColor)
.frame(width: 4)
}
}
// MARK: - Sport Extension for Theme Colors
extension Sport {
var themeColor: Color {
switch self {
case .mlb: return Theme.mlbRed
case .nba: return Theme.nbaOrange
case .nhl: return Theme.nhlBlue
case .nfl: return Theme.nflBrown
case .mls: return Theme.mlsGreen
case .wnba: return Theme.wnbaPurple
case .nwsl: return Theme.nwslTeal
}
}
}