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>
This commit is contained in:
Trey t
2026-02-11 09:27:23 -06:00
parent e9c15d70b1
commit d63d311cab
77 changed files with 982 additions and 263 deletions

View File

@@ -46,7 +46,11 @@
"WebFetch(domain:swiftpackageindex.com)", "WebFetch(domain:swiftpackageindex.com)",
"WebFetch(domain:posthog.com)", "WebFetch(domain:posthog.com)",
"WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:raw.githubusercontent.com)",
"Bash(swift package add-dependency:*)" "Bash(swift package add-dependency:*)",
"WebFetch(domain:www.hackingwithswift.com)",
"WebFetch(domain:forums.developer.apple.com)",
"WebFetch(domain:www.codegenes.net)",
"WebFetch(domain:medium.com)"
] ]
} }
} }

View File

@@ -346,6 +346,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTime.entitlements; CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTime.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QND55P4443; DEVELOPMENT_TEAM = QND55P4443;
@@ -365,6 +366,7 @@
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.SportsTime; PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.SportsTime;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;

View File

@@ -143,9 +143,6 @@ final class AnalyticsManager {
var props: [String: Any] = ["screen_name": screenName] var props: [String: Any] = ["screen_name": screenName]
if let properties { props.merge(properties) { _, new in new } } if let properties { props.merge(properties) { _, new in new } }
#if DEBUG
print("[Analytics] screen_viewed: \(screenName)")
#endif
PostHogSDK.shared.capture("screen_viewed", properties: props) PostHogSDK.shared.capture("screen_viewed", properties: props)
} }

View File

@@ -0,0 +1,14 @@
//
// CloudKitContainerConfig.swift
// SportsTime
//
import CloudKit
enum CloudKitContainerConfig {
nonisolated static let identifier = "iCloud.com.88oakapps.SportsTime"
nonisolated static func makeContainer() -> CKContainer {
CKContainer(identifier: identifier)
}
}

View File

@@ -109,8 +109,7 @@ actor CloudKitService {
private let deltaOverlapSeconds: TimeInterval = 120 private let deltaOverlapSeconds: TimeInterval = 120
private init() { private init() {
// Use target entitlements (debug/prod) instead of hardcoding a container ID. self.container = CloudKitContainerConfig.makeContainer()
self.container = CKContainer.default()
self.publicDatabase = container.publicCloudDatabase self.publicDatabase = container.publicCloudDatabase
} }

View File

@@ -5,7 +5,7 @@ import CloudKit
actor ItineraryItemService { actor ItineraryItemService {
static let shared = ItineraryItemService() static let shared = ItineraryItemService()
private let container = CKContainer.default() private let container = CloudKitContainerConfig.makeContainer()
private var database: CKDatabase { container.privateCloudDatabase } private var database: CKDatabase { container.privateCloudDatabase }
private let recordType = "ItineraryItem" private let recordType = "ItineraryItem"

View File

@@ -108,8 +108,9 @@ struct LocationPermissionView: View {
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: "location.circle.fill") Image(systemName: "location.circle.fill")
.font(.system(size: 60)) .font(.largeTitle)
.foregroundStyle(.blue) .foregroundStyle(.blue)
.accessibilityHidden(true)
Text("Enable Location") Text("Enable Location")
.font(.title2) .font(.title2)

View File

@@ -52,8 +52,7 @@ actor PollService {
private var pollSubscriptionID: CKSubscription.ID? private var pollSubscriptionID: CKSubscription.ID?
private init() { private init() {
// Respect target entitlements so Debug and production stay isolated. self.container = CloudKitContainerConfig.makeContainer()
self.container = CKContainer.default()
self.publicDatabase = container.publicCloudDatabase self.publicDatabase = container.publicCloudDatabase
} }

View File

@@ -63,7 +63,7 @@ final class VisitPhotoService {
init(modelContext: ModelContext) { init(modelContext: ModelContext) {
self.modelContext = modelContext self.modelContext = modelContext
self.container = CKContainer.default() self.container = CloudKitContainerConfig.makeContainer()
self.privateDatabase = container.privateCloudDatabase self.privateDatabase = container.privateCloudDatabase
} }

View File

@@ -28,7 +28,9 @@ struct AnimatedSportsBackground: View {
AnimatedSportsIcon(index: index, animate: animate) AnimatedSportsIcon(index: index, animate: animate)
} }
} }
.accessibilityHidden(true)
.onAppear { .onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: 5.0).repeatForever(autoreverses: true)) { withAnimation(.easeInOut(duration: 5.0).repeatForever(autoreverses: true)) {
animate = true animate = true
} }
@@ -173,6 +175,8 @@ struct AnimatedSportsIcon: View {
} }
private func triggerGlow() { private func triggerGlow() {
guard !Theme.Animation.prefersReducedMotion else { return }
// Slow fade in // Slow fade in
withAnimation(.easeIn(duration: 0.8)) { withAnimation(.easeIn(duration: 0.8)) {
glowOpacity = 1 glowOpacity = 1

View File

@@ -68,7 +68,9 @@ struct AnimatedRouteGraphic: View {
) )
} }
} }
.accessibilityHidden(true)
.onAppear { .onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: Theme.Animation.routeDrawDuration).repeatForever(autoreverses: false)) { withAnimation(.easeInOut(duration: Theme.Animation.routeDrawDuration).repeatForever(autoreverses: false)) {
animationProgress = 1 animationProgress = 1
} }
@@ -126,7 +128,9 @@ struct PulsingDot: View {
.frame(width: size, height: size) .frame(width: size, height: size)
.shadow(color: color.opacity(0.5), radius: 4) .shadow(color: color.opacity(0.5), radius: 4)
} }
.accessibilityHidden(true)
.onAppear { .onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeOut(duration: 1.5).repeatForever(autoreverses: false)) { withAnimation(.easeOut(duration: 1.5).repeatForever(autoreverses: false)) {
isPulsing = true isPulsing = true
} }
@@ -171,6 +175,7 @@ struct RoutePreviewStrip: View {
} }
.padding(.horizontal, Theme.Spacing.md) .padding(.horizontal, Theme.Spacing.md)
} }
.accessibilityHidden(true)
} }
private func abbreviateCity(_ city: String) -> String { private func abbreviateCity(_ city: String) -> String {
@@ -196,6 +201,7 @@ struct StatPill: View {
Text(value) Text(value)
.font(.footnote) .font(.footnote)
} }
.accessibilityElement(children: .combine)
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
@@ -219,6 +225,7 @@ struct EmptyStateView: View {
Image(systemName: icon) Image(systemName: icon)
.font(.largeTitle) .font(.largeTitle)
.foregroundStyle(Theme.warmOrange.opacity(0.7)) .foregroundStyle(Theme.warmOrange.opacity(0.7))
.accessibilityHidden(true)
VStack(spacing: 8) { VStack(spacing: 8) {
Text(title) Text(title)

View File

@@ -47,7 +47,9 @@ struct PlaceholderRectangle: View {
.fill(placeholderColor) .fill(placeholderColor)
.frame(width: width, height: height) .frame(width: width, height: height)
.opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity) .opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity)
.accessibilityHidden(true)
.onAppear { .onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) {
isAnimating = true isAnimating = true
} }
@@ -72,7 +74,9 @@ struct PlaceholderCircle: View {
.fill(placeholderColor) .fill(placeholderColor)
.frame(width: diameter, height: diameter) .frame(width: diameter, height: diameter)
.opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity) .opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity)
.accessibilityHidden(true)
.onAppear { .onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) {
isAnimating = true isAnimating = true
} }
@@ -98,7 +102,9 @@ struct PlaceholderCapsule: View {
.fill(placeholderColor) .fill(placeholderColor)
.frame(width: width, height: height) .frame(width: width, height: height)
.opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity) .opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity)
.accessibilityHidden(true)
.onAppear { .onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) {
isAnimating = true isAnimating = true
} }
@@ -145,7 +151,10 @@ struct PlaceholderCard: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large) RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
} }
.accessibilityElement(children: .ignore)
.accessibilityLabel("Loading content")
.onAppear { .onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) {
isAnimating = true isAnimating = true
} }
@@ -185,6 +194,7 @@ struct PlaceholderListRow: View {
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.onAppear { .onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) {
isAnimating = true isAnimating = true
} }

View File

@@ -25,10 +25,11 @@ struct LoadingSheet: View {
// Dimmed background // Dimmed background
Color.black.opacity(Self.backgroundOpacity) Color.black.opacity(Self.backgroundOpacity)
.ignoresSafeArea() .ignoresSafeArea()
.accessibilityHidden(true)
// Centered card // Centered card
VStack(spacing: Theme.Spacing.lg) { VStack(spacing: Theme.Spacing.lg) {
LoadingSpinner(size: .large) LoadingSpinner(size: .large, label: label)
VStack(spacing: Theme.Spacing.xs) { VStack(spacing: Theme.Spacing.xs) {
Text(label) Text(label)

View File

@@ -56,6 +56,7 @@ struct LoadingSpinner: View {
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
} }
} }
.accessibilityLabel(label ?? "Loading")
} }
private var spinnerView: some View { private var spinnerView: some View {
@@ -63,15 +64,18 @@ struct LoadingSpinner: View {
// Background track - subtle gray like Apple's native spinner // Background track - subtle gray like Apple's native spinner
Circle() Circle()
.stroke(Color.secondary.opacity(0.2), lineWidth: size.strokeWidth) .stroke(Color.secondary.opacity(0.2), lineWidth: size.strokeWidth)
.accessibilityHidden(true)
// Rotating arc (270 degrees) - gray like Apple's ProgressView // Rotating arc (270 degrees) - gray like Apple's ProgressView
Circle() Circle()
.trim(from: 0, to: 0.75) .trim(from: 0, to: 0.75)
.stroke(Color.secondary, style: StrokeStyle(lineWidth: size.strokeWidth, lineCap: .round)) .stroke(Color.secondary, style: StrokeStyle(lineWidth: size.strokeWidth, lineCap: .round))
.rotationEffect(.degrees(rotation)) .rotationEffect(.degrees(rotation))
.accessibilityHidden(true)
} }
.frame(width: size.diameter, height: size.diameter) .frame(width: size.diameter, height: size.diameter)
.onAppear { .onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
rotation = 360 rotation = 360
} }

View File

@@ -90,6 +90,7 @@ struct SportActionButton: View {
Image(systemName: sport.iconName) Image(systemName: sport.iconName)
.font(.title3) .font(.title3)
.foregroundStyle(sport.themeColor) .foregroundStyle(sport.themeColor)
.accessibilityHidden(true)
} }
Text(sport.rawValue) Text(sport.rawValue)
@@ -100,13 +101,15 @@ struct SportActionButton: View {
.scaleEffect(isPressed ? 0.9 : 1.0) .scaleEffect(isPressed ? 0.9 : 1.0)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(sport.rawValue)
.accessibilityAddTraits(.isButton)
.simultaneousGesture( .simultaneousGesture(
DragGesture(minimumDistance: 0) DragGesture(minimumDistance: 0)
.onChanged { _ in .onChanged { _ in
withAnimation(Theme.Animation.spring) { isPressed = true } Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = true }
} }
.onEnded { _ in .onEnded { _ in
withAnimation(Theme.Animation.spring) { isPressed = false } Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = false }
} }
) )
} }
@@ -140,23 +143,27 @@ struct SportToggleButton: View {
Image(systemName: sport.iconName) Image(systemName: sport.iconName)
.font(.title3) .font(.title3)
.foregroundStyle(isSelected ? .white : sport.themeColor) .foregroundStyle(isSelected ? .white : sport.themeColor)
.accessibilityHidden(true)
} }
Text(sport.rawValue) Text(sport.rawValue)
.font(.system(size: 10, weight: isSelected ? .semibold : .medium)) .font(.caption2.weight(isSelected ? .semibold : .medium))
.foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme)) .foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme))
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.scaleEffect(isPressed ? 0.9 : 1.0) .scaleEffect(isPressed ? 0.9 : 1.0)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(sport.rawValue)
.accessibilityAddTraits(.isToggle)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.simultaneousGesture( .simultaneousGesture(
DragGesture(minimumDistance: 0) DragGesture(minimumDistance: 0)
.onChanged { _ in .onChanged { _ in
withAnimation(Theme.Animation.spring) { isPressed = true } Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = true }
} }
.onEnded { _ in .onEnded { _ in
withAnimation(Theme.Animation.spring) { isPressed = false } Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = false }
} }
) )
} }
@@ -190,6 +197,7 @@ struct SportProgressButton: View {
Image(systemName: sport.iconName) Image(systemName: sport.iconName)
.font(.title3) .font(.title3)
.foregroundStyle(isSelected ? sport.themeColor : Theme.textMuted(colorScheme)) .foregroundStyle(isSelected ? sport.themeColor : Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
Text(sport.rawValue) Text(sport.rawValue)
@@ -200,13 +208,17 @@ struct SportProgressButton: View {
.scaleEffect(isPressed ? 0.9 : 1.0) .scaleEffect(isPressed ? 0.9 : 1.0)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("\(sport.rawValue), \(Int(progress * 100)) percent visited")
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(.isButton)
.accessibilityAddTraits(isSelected ? .isSelected : [])
.simultaneousGesture( .simultaneousGesture(
DragGesture(minimumDistance: 0) DragGesture(minimumDistance: 0)
.onChanged { _ in .onChanged { _ in
withAnimation(Theme.Animation.spring) { isPressed = true } Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = true }
} }
.onEnded { _ in .onEnded { _ in
withAnimation(Theme.Animation.spring) { isPressed = false } Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = false }
} }
) )
} }

View File

@@ -259,7 +259,15 @@ enum Theme {
} }
static var darkSurfaceGlow: Color { static var darkSurfaceGlow: Color {
warmOrange.opacity(0.15) switch current {
case .teal: return Color(hex: "92AEAB")
case .orbit: return Color(hex: "708BAA")
case .retro: return Color(hex: "87A0BA")
case .clutch: return Color(hex: "6D8399")
case .monochrome: return Color(hex: "707070")
case .sunset: return Color(hex: "84719A")
case .midnight: return Color(hex: "7F95B0")
}
} }
static var darkTextPrimary: Color { static var darkTextPrimary: Color {
@@ -288,13 +296,13 @@ enum Theme {
static var darkTextMuted: Color { static var darkTextMuted: Color {
switch current { switch current {
case .teal: return Color(hex: "7FADA8") case .teal: return Color(hex: "B4CFCC")
case .orbit: return Color(hex: "8090A0") case .orbit: return Color(hex: "A0B1C3")
case .retro: return Color(hex: "7898B8") case .retro: return Color(hex: "A4BBCF")
case .clutch: return Color(hex: "8898A8") case .clutch: return Color(hex: "A8B7C6")
case .monochrome: return Color(hex: "707070") case .monochrome: return Color(hex: "B0B0B0")
case .sunset: return Color(hex: "9D8AA8") case .sunset: return Color(hex: "C9B5D3")
case .midnight: return Color(hex: "64748B") case .midnight: return Color(hex: "A3B3C8")
} }
} }
@@ -329,7 +337,15 @@ enum Theme {
static var lightCardBackgroundElevated: Color { lightBackground1 } static var lightCardBackgroundElevated: Color { lightBackground1 }
static var lightSurfaceBorder: Color { static var lightSurfaceBorder: Color {
warmOrange.opacity(0.3) switch current {
case .teal: return Color(hex: "568E88")
case .orbit: return Color(hex: "5F86AE")
case .retro: return Color(hex: "5D90B8")
case .clutch: return Color(hex: "6E8194")
case .monochrome: return Color(hex: "7A7A7A")
case .sunset: return Color(hex: "B98474")
case .midnight: return Color(hex: "7789A3")
}
} }
static var lightTextPrimary: Color { static var lightTextPrimary: Color {
@@ -358,12 +374,12 @@ enum Theme {
static var lightTextMuted: Color { static var lightTextMuted: Color {
switch current { switch current {
case .teal: return Color(hex: "5A9A94") case .teal: return Color(hex: "497F79")
case .orbit: return Color(hex: "5A7A9A") case .orbit: return Color(hex: "537596")
case .retro: return Color(hex: "5A8AAA") case .retro: return Color(hex: "4A7A99")
case .clutch: return Color(hex: "6A7A8A") case .clutch: return Color(hex: "5A6B7E")
case .monochrome: return Color(hex: "707070") case .monochrome: return Color(hex: "707070")
case .sunset: return Color(hex: "9A6A5A") case .sunset: return Color(hex: "8A5A4A")
case .midnight: return Color(hex: "64748B") case .midnight: return Color(hex: "64748B")
} }
} }
@@ -442,6 +458,20 @@ enum Theme {
static var gentleSpring: SwiftUI.Animation { static var gentleSpring: SwiftUI.Animation {
.spring(response: 0.5, dampingFraction: 0.8) .spring(response: 0.5, dampingFraction: 0.8)
} }
/// Whether the system Reduce Motion preference is enabled.
static var prefersReducedMotion: Bool {
UIAccessibility.isReduceMotionEnabled
}
/// Performs a state change with animation, or instantly if Reduce Motion is enabled.
static func withMotion(_ animation: SwiftUI.Animation = spring, _ body: () -> Void) {
if prefersReducedMotion {
body()
} else {
withAnimation(animation) { body() }
}
}
} }
} }

View File

@@ -61,7 +61,10 @@ struct PressableButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label configuration.label
.scaleEffect(configuration.isPressed ? scale : 1.0) .scaleEffect(configuration.isPressed ? scale : 1.0)
.animation(Theme.Animation.spring, value: configuration.isPressed) .animation(
Theme.Animation.prefersReducedMotion ? nil : Theme.Animation.spring,
value: configuration.isPressed
)
} }
} }
@@ -71,6 +74,36 @@ extension View {
} }
} }
// 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 // MARK: - Shimmer Effect Modifier
struct ShimmerEffect: ViewModifier { struct ShimmerEffect: ViewModifier {
@@ -79,22 +112,25 @@ struct ShimmerEffect: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.overlay { .overlay {
GeometryReader { geo in if !Theme.Animation.prefersReducedMotion {
LinearGradient( GeometryReader { geo in
colors: [ LinearGradient(
.clear, colors: [
Color.white.opacity(0.3), .clear,
.clear Color.white.opacity(0.3),
], .clear
startPoint: .leading, ],
endPoint: .trailing startPoint: .leading,
) endPoint: .trailing
.frame(width: geo.size.width * 2) )
.offset(x: -geo.size.width + (geo.size.width * 2 * phase)) .frame(width: geo.size.width * 2)
.offset(x: -geo.size.width + (geo.size.width * 2 * phase))
}
.mask(content)
} }
.mask(content)
} }
.onAppear { .onAppear {
guard !Theme.Animation.prefersReducedMotion else { return }
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1 phase = 1
} }
@@ -120,8 +156,12 @@ struct StaggeredAnimation: ViewModifier {
.opacity(appeared ? 1 : 0) .opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 20) .offset(y: appeared ? 0 : 20)
.onAppear { .onAppear {
withAnimation(Theme.Animation.spring.delay(Double(index) * delay)) { if Theme.Animation.prefersReducedMotion {
appeared = true appeared = true
} else {
withAnimation(Theme.Animation.spring.delay(Double(index) * delay)) {
appeared = true
}
} }
} }
} }
@@ -184,7 +224,7 @@ struct ThemedBackground: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.background { .background {
if DesignStyleManager.shared.animationsEnabled { if DesignStyleManager.shared.animationsEnabled && !Theme.Animation.prefersReducedMotion {
AnimatedSportsBackground() AnimatedSportsBackground()
.ignoresSafeArea() .ignoresSafeArea()
} else { } else {

View File

@@ -25,6 +25,7 @@ struct ShareButton<Content: ShareableContent>: View {
switch style { switch style {
case .icon: case .icon:
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
.accessibilityLabel("Share")
case .labeled: case .labeled:
Label("Share", systemImage: "square.and.arrow.up") Label("Share", systemImage: "square.and.arrow.up")
case .pill: case .pill:

View File

@@ -81,6 +81,7 @@ struct SharePreviewView<Content: ShareableContent>: View {
.frame(maxHeight: 400) .frame(maxHeight: 400)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5)
.accessibilityLabel("Share card preview")
} else if isGenerating { } else if isGenerating {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(Theme.cardBackground(colorScheme)) .fill(Theme.cardBackground(colorScheme))
@@ -147,6 +148,9 @@ struct SharePreviewView<Content: ShareableContent>: View {
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Select \(theme.name) theme")
.accessibilityHint(selectedTheme.id == theme.id ? "Currently selected" : "Double-tap to select this theme")
.accessibilityAddTraits(selectedTheme.id == theme.id ? .isSelected : [])
} }
// MARK: - Action Buttons // MARK: - Action Buttons
@@ -159,6 +163,7 @@ struct SharePreviewView<Content: ShareableContent>: View {
} label: { } label: {
HStack { HStack {
Image(systemName: "camera.fill") Image(systemName: "camera.fill")
.accessibilityHidden(true)
Text("Share to Instagram") Text("Share to Instagram")
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -175,6 +180,7 @@ struct SharePreviewView<Content: ShareableContent>: View {
} label: { } label: {
HStack { HStack {
Image(systemName: "doc.on.doc") Image(systemName: "doc.on.doc")
.accessibilityHidden(true)
Text("Copy to Clipboard") Text("Copy to Clipboard")
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -195,6 +201,7 @@ struct SharePreviewView<Content: ShareableContent>: View {
HStack { HStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.accessibilityHidden(true)
Text("Copied to clipboard") Text("Copied to clipboard")
} }
.padding() .padding()

View File

@@ -40,6 +40,7 @@ struct HomeView: View {
.font(.title2) .font(.title2)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
} }
.accessibilityLabel("Create new trip")
} }
} }
} }
@@ -198,6 +199,9 @@ struct HomeView: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
} }
.minimumHitTarget()
.accessibilityLabel("Refresh trips")
.accessibilityHint("Fetches the latest featured trip recommendations")
} }
.padding(.horizontal, Theme.Spacing.md) .padding(.horizontal, Theme.Spacing.md)
@@ -210,6 +214,7 @@ struct HomeView: View {
HStack(spacing: Theme.Spacing.xs) { HStack(spacing: Theme.Spacing.xs) {
Image(systemName: regionGroup.region.iconName) Image(systemName: regionGroup.region.iconName)
.font(.caption) .font(.caption)
.accessibilityHidden(true)
Text(regionGroup.region.shortName) Text(regionGroup.region.shortName)
.font(.subheadline) .font(.subheadline)
} }
@@ -246,6 +251,7 @@ struct HomeView: View {
HStack { HStack {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange) .foregroundStyle(.orange)
.accessibilityLabel("Error loading trips")
Text(error) Text(error)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
@@ -284,6 +290,7 @@ struct HomeView: View {
Text("See All") Text("See All")
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.accessibilityHidden(true)
} }
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
@@ -348,7 +355,9 @@ struct SavedTripCard: View {
Image(systemName: "map.fill") Image(systemName: "map.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(trip.displayName) Text(trip.displayName)
@@ -363,11 +372,13 @@ struct SavedTripCard: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "mappin") Image(systemName: "mappin")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
Text("\(trip.stops.count) cities") Text("\(trip.stops.count) cities")
} }
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "sportscourt") Image(systemName: "sportscourt")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
Text("\(trip.totalGames) games") Text("\(trip.totalGames) games")
} }
} }
@@ -380,6 +391,7 @@ struct SavedTripCard: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme)) .background(Theme.cardBackground(colorScheme))
@@ -388,6 +400,7 @@ struct SavedTripCard: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
} }
.accessibilityElement(children: .combine)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -410,6 +423,7 @@ struct TipRow: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.routeGold) .foregroundStyle(Theme.routeGold)
} }
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(title) Text(title)
@@ -496,6 +510,7 @@ struct SavedTripsListView: View {
Image(systemName: "plus.circle") Image(systemName: "plus.circle")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
} }
.accessibilityLabel("Create poll")
} }
} }
@@ -522,6 +537,7 @@ struct SavedTripsListView: View {
Image(systemName: "person.3") Image(systemName: "person.3")
.font(.title) .font(.title)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
Text("No group polls yet") Text("No group polls yet")
.font(.subheadline) .font(.subheadline)
@@ -563,6 +579,7 @@ struct SavedTripsListView: View {
Image(systemName: "suitcase") Image(systemName: "suitcase")
.font(.largeTitle) .font(.largeTitle)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.accessibilityHidden(true)
Text("No Saved Trips") Text("No Saved Trips")
.font(.headline) .font(.headline)
@@ -621,7 +638,9 @@ private struct PollRowCard: View {
Image(systemName: "chart.bar.doc.horizontal") Image(systemName: "chart.bar.doc.horizontal")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: Theme.Spacing.xs) { VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(poll.title) Text(poll.title)
@@ -644,6 +663,7 @@ private struct PollRowCard: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme)) .background(Theme.cardBackground(colorScheme))
@@ -676,6 +696,7 @@ struct SavedTripListRow: View {
} }
} }
.frame(width: 20) .frame(width: 20)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: Theme.Spacing.xs) { VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(trip.displayName) Text(trip.displayName)
@@ -703,6 +724,7 @@ struct SavedTripListRow: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.lg) .padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme)) .background(Theme.cardBackground(colorScheme))
@@ -712,6 +734,7 @@ struct SavedTripListRow: View {
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
} }
.shadow(color: Theme.cardShadow(colorScheme), radius: 8, y: 4) .shadow(color: Theme.cardShadow(colorScheme), radius: 8, y: 4)
.accessibilityElement(children: .combine)
} }
} }
@@ -733,9 +756,11 @@ struct ProLockedView: View {
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
Image(systemName: "lock.fill") Image(systemName: "lock.fill")
.font(.system(size: 40)) .font(.largeTitle)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
.accessibilityLabel("Pro feature locked")
VStack(spacing: Theme.Spacing.sm) { VStack(spacing: Theme.Spacing.sm) {
Text(feature.displayName) Text(feature.displayName)
@@ -762,6 +787,7 @@ struct ProLockedView: View {
.background(Theme.warmOrange) .background(Theme.warmOrange)
.foregroundStyle(.white) .foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.accessibilityElement(children: .combine)
} }
.padding(.horizontal, Theme.Spacing.xl) .padding(.horizontal, Theme.Spacing.xl)

View File

@@ -32,6 +32,7 @@ struct SuggestedTripCard: View {
Image(systemName: sport.iconName) Image(systemName: sport.iconName)
.font(.caption) .font(.caption)
.foregroundStyle(sport.themeColor) .foregroundStyle(sport.themeColor)
.accessibilityHidden(true)
} }
} }
} }
@@ -76,12 +77,14 @@ struct SuggestedTripCard: View {
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
} }
.shadow(color: Theme.cardShadow(colorScheme), radius: 8, y: 4) .shadow(color: Theme.cardShadow(colorScheme), radius: 8, y: 4)
.accessibilityElement(children: .combine)
} }
private var routePreview: some View { private var routePreview: some View {
let cities = suggestedTrip.trip.stops.map { $0.city } let cities = suggestedTrip.trip.stops.map { $0.city }
let startCity = cities.first ?? "" let startCity = cities.first ?? ""
let endCity = cities.last ?? "" let endCity = cities.last ?? ""
let routeDescription = cities.joined(separator: " to ")
return VStack(alignment: .leading, spacing: Theme.Spacing.xs) { return VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
// Start End display // Start End display
@@ -94,6 +97,7 @@ struct SuggestedTripCard: View {
Image(systemName: "arrow.right") Image(systemName: "arrow.right")
.font(.caption2) .font(.caption2)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text(endCity) Text(endCity)
.font(.subheadline) .font(.subheadline)
@@ -108,11 +112,13 @@ struct SuggestedTripCard: View {
Circle() Circle()
.fill(index == 0 || index == cities.count - 1 ? Theme.warmOrange : Theme.routeGold.opacity(0.6)) .fill(index == 0 || index == cities.count - 1 ? Theme.warmOrange : Theme.routeGold.opacity(0.6))
.frame(width: 6, height: 6) .frame(width: 6, height: 6)
.accessibilityHidden(true)
if index < cities.count - 1 { if index < cities.count - 1 {
Rectangle() Rectangle()
.fill(Theme.routeGold.opacity(0.4)) .fill(Theme.routeGold.opacity(0.4))
.frame(width: 8, height: 2) .frame(width: 8, height: 2)
.accessibilityHidden(true)
} }
} }
} }
@@ -120,6 +126,8 @@ struct SuggestedTripCard: View {
} }
.frame(height: 12) .frame(height: 12)
} }
.accessibilityElement(children: .ignore)
.accessibilityLabel("Route: \(routeDescription)")
} }
private var regionColor: Color { private var regionColor: Color {

View File

@@ -118,6 +118,8 @@ struct HomeContent_Classic: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
} }
.minimumHitTarget()
.accessibilityLabel("Refresh trips")
} }
.padding(.horizontal, Theme.Spacing.md) .padding(.horizontal, Theme.Spacing.md)
@@ -130,6 +132,7 @@ struct HomeContent_Classic: View {
HStack(spacing: Theme.Spacing.xs) { HStack(spacing: Theme.Spacing.xs) {
Image(systemName: regionGroup.region.iconName) Image(systemName: regionGroup.region.iconName)
.font(.caption) .font(.caption)
.accessibilityHidden(true)
Text(regionGroup.region.shortName) Text(regionGroup.region.shortName)
.font(.subheadline) .font(.subheadline)
} }
@@ -162,6 +165,7 @@ struct HomeContent_Classic: View {
HStack { HStack {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange) .foregroundStyle(.orange)
.accessibilityLabel("Error loading trips")
Text(error) Text(error)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
@@ -200,6 +204,7 @@ struct HomeContent_Classic: View {
Text("See All") Text("See All")
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.accessibilityHidden(true)
} }
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
@@ -230,7 +235,9 @@ struct HomeContent_Classic: View {
Image(systemName: "map.fill") Image(systemName: "map.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(trip.displayName) Text(trip.displayName)
@@ -245,11 +252,13 @@ struct HomeContent_Classic: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "mappin") Image(systemName: "mappin")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
Text("\(trip.stops.count) cities") Text("\(trip.stops.count) cities")
} }
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "sportscourt") Image(systemName: "sportscourt")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
Text("\(trip.totalGames) games") Text("\(trip.totalGames) games")
} }
} }
@@ -262,6 +271,7 @@ struct HomeContent_Classic: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme)) .background(Theme.cardBackground(colorScheme))
@@ -270,6 +280,7 @@ struct HomeContent_Classic: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
} }
.accessibilityElement(children: .combine)
} }
// MARK: - Tips Section // MARK: - Tips Section
@@ -306,6 +317,7 @@ struct HomeContent_Classic: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.routeGold) .foregroundStyle(Theme.routeGold)
} }
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(title) Text(title)

View File

@@ -117,6 +117,8 @@ struct HomeContent_ClassicAnimated: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
} }
.minimumHitTarget()
.accessibilityLabel("Refresh trips")
} }
.padding(.horizontal, Theme.Spacing.md) .padding(.horizontal, Theme.Spacing.md)
@@ -129,6 +131,7 @@ struct HomeContent_ClassicAnimated: View {
HStack(spacing: Theme.Spacing.xs) { HStack(spacing: Theme.Spacing.xs) {
Image(systemName: regionGroup.region.iconName) Image(systemName: regionGroup.region.iconName)
.font(.caption) .font(.caption)
.accessibilityHidden(true)
Text(regionGroup.region.shortName) Text(regionGroup.region.shortName)
.font(.subheadline) .font(.subheadline)
} }
@@ -161,6 +164,7 @@ struct HomeContent_ClassicAnimated: View {
HStack { HStack {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange) .foregroundStyle(.orange)
.accessibilityLabel("Error loading trips")
Text(error) Text(error)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
@@ -199,6 +203,7 @@ struct HomeContent_ClassicAnimated: View {
Text("See All") Text("See All")
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.accessibilityHidden(true)
} }
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
@@ -229,7 +234,9 @@ struct HomeContent_ClassicAnimated: View {
Image(systemName: "map.fill") Image(systemName: "map.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(trip.displayName) Text(trip.displayName)
@@ -244,11 +251,13 @@ struct HomeContent_ClassicAnimated: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "mappin") Image(systemName: "mappin")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
Text("\(trip.stops.count) cities") Text("\(trip.stops.count) cities")
} }
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "sportscourt") Image(systemName: "sportscourt")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
Text("\(trip.totalGames) games") Text("\(trip.totalGames) games")
} }
} }
@@ -261,6 +270,7 @@ struct HomeContent_ClassicAnimated: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme)) .background(Theme.cardBackground(colorScheme))
@@ -269,6 +279,7 @@ struct HomeContent_ClassicAnimated: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
} }
.accessibilityElement(children: .combine)
} }
// MARK: - Tips Section // MARK: - Tips Section
@@ -305,6 +316,7 @@ struct HomeContent_ClassicAnimated: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.routeGold) .foregroundStyle(Theme.routeGold)
} }
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(title) Text(title)

View File

@@ -27,6 +27,8 @@ struct ProGateModifier: ViewModifier {
.onTapGesture { .onTapGesture {
showPaywall = true showPaywall = true
} }
.accessibilityLabel("Pro feature locked")
.accessibilityHint("Double-tap to view upgrade options")
} }
} }
.sheet(isPresented: $showPaywall) { .sheet(isPresented: $showPaywall) {

View File

@@ -96,8 +96,10 @@ struct TripRoutesBackground: View {
// Animated car/plane icon traveling along a route // Animated car/plane icon traveling along a route
TravelingIcon(color: color, animate: animate) TravelingIcon(color: color, animate: animate)
} }
.accessibilityHidden(true)
.onAppear { .onAppear {
withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) { guard !Theme.Animation.prefersReducedMotion else { return }
Theme.Animation.withMotion(.easeInOut(duration: 2).repeatForever(autoreverses: true)) {
animate = true animate = true
} }
} }
@@ -115,13 +117,15 @@ private struct TravelingIcon: View {
Image(systemName: "car.fill") Image(systemName: "car.fill")
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundStyle(color.opacity(0.3)) .foregroundStyle(color.opacity(0.3))
.accessibilityHidden(true)
.position( .position(
x: geo.size.width * (0.15 + position * 0.7), x: geo.size.width * (0.15 + position * 0.7),
y: geo.size.height * (0.2 + sin(position * .pi * 2) * 0.15) y: geo.size.height * (0.2 + sin(position * .pi * 2) * 0.15)
) )
} }
.onAppear { .onAppear {
withAnimation(.linear(duration: 8).repeatForever(autoreverses: false)) { guard !Theme.Animation.prefersReducedMotion else { return }
Theme.Animation.withMotion(.linear(duration: 8).repeatForever(autoreverses: false)) {
position = 1 position = 1
} }
} }
@@ -176,6 +180,7 @@ struct DocumentsBackground: View {
ForEach(0..<12, id: \.self) { index in ForEach(0..<12, id: \.self) { index in
documentIcon(index: index) documentIcon(index: index)
} }
.accessibilityHidden(true)
// Central PDF badge // Central PDF badge
GeometryReader { geo in GeometryReader { geo in
@@ -196,8 +201,10 @@ struct DocumentsBackground: View {
.scaleEffect(animate ? 1.05 : 0.95) .scaleEffect(animate ? 1.05 : 0.95)
} }
} }
.accessibilityHidden(true)
.onAppear { .onAppear {
withAnimation(.easeInOut(duration: 2.5).repeatForever(autoreverses: true)) { guard !Theme.Animation.prefersReducedMotion else { return }
Theme.Animation.withMotion(.easeInOut(duration: 2.5).repeatForever(autoreverses: true)) {
animate = true animate = true
} }
} }
@@ -251,7 +258,7 @@ struct StadiumMapBackground: View {
var body: some View { var body: some View {
ZStack { ZStack {
// Map grid canvas // Map grid canvas (decorative)
Canvas { context, size in Canvas { context, size in
// Draw subtle grid lines like a map // Draw subtle grid lines like a map
let gridSpacing: CGFloat = 35 let gridSpacing: CGFloat = 35
@@ -357,11 +364,13 @@ struct StadiumMapBackground: View {
.position(x: geo.size.width * 0.5, y: geo.size.height * 0.92) .position(x: geo.size.width * 0.5, y: geo.size.height * 0.92)
} }
} }
.accessibilityHidden(true)
.onAppear { .onAppear {
withAnimation(.easeInOut(duration: 2.5).repeatForever(autoreverses: true)) { guard !Theme.Animation.prefersReducedMotion else { return }
Theme.Animation.withMotion(.easeInOut(duration: 2.5).repeatForever(autoreverses: true)) {
animate = true animate = true
} }
withAnimation(.spring(response: 0.6, dampingFraction: 0.6).delay(0.3)) { Theme.Animation.withMotion(.spring(response: 0.6, dampingFraction: 0.6).delay(0.3)) {
checkmarkScale = 1 checkmarkScale = 1
} }
} }
@@ -459,7 +468,10 @@ struct OnboardingPaywallView: View {
.tag(pages.count) .tag(pages.count)
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.animation(.easeInOut, value: currentPage) .animation(
Theme.Animation.prefersReducedMotion ? nil : .easeInOut,
value: currentPage
)
// Page indicator // Page indicator
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -500,8 +512,9 @@ struct OnboardingPaywallView: View {
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
Image(systemName: page.icon) Image(systemName: page.icon)
.font(.system(size: 44)) .font(.largeTitle)
.foregroundStyle(page.color) .foregroundStyle(page.color)
.accessibilityHidden(true)
} }
VStack(spacing: Theme.Spacing.sm) { VStack(spacing: Theme.Spacing.sm) {
@@ -521,6 +534,7 @@ struct OnboardingPaywallView: View {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(page.color) .foregroundStyle(page.color)
.font(.body) .font(.body)
.accessibilityHidden(true)
Text(bullet) Text(bullet)
.font(.body) .font(.body)
@@ -582,6 +596,7 @@ struct OnboardingPaywallView: View {
.foregroundStyle(.white) .foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
} }
.accessibilityHint("Page \(currentPage + 1) of \(pages.count + 1)")
} }
// Continue free (always visible) // Continue free (always visible)
@@ -594,6 +609,7 @@ struct OnboardingPaywallView: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
} }
.accessibilityHint("Skip and continue with free version")
} }
} }

View File

@@ -23,8 +23,9 @@ struct PaywallView: View {
SubscriptionStoreView(subscriptions: storeManager.products.filter { $0.subscription != nil }) { SubscriptionStoreView(subscriptions: storeManager.products.filter { $0.subscription != nil }) {
VStack(spacing: Theme.Spacing.md) { VStack(spacing: Theme.Spacing.md) {
Image(systemName: "star.circle.fill") Image(systemName: "star.circle.fill")
.font(.system(size: 60)) .font(.system(.largeTitle, design: .default).weight(.regular))
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Upgrade to Pro") Text("Upgrade to Pro")
.font(.largeTitle.bold()) .font(.largeTitle.bold())
@@ -79,10 +80,12 @@ struct PaywallView: View {
private func featurePill(icon: String, text: String) -> some View { private func featurePill(icon: String, text: String) -> some View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 11)) .font(.caption2)
.accessibilityHidden(true)
Text(text) Text(text)
.font(.caption2) .font(.caption2)
} }
.accessibilityElement(children: .combine)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 6) .padding(.vertical, 6)

View File

@@ -15,6 +15,7 @@ struct ProBadge: View {
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
.background(Theme.warmOrange, in: Capsule()) .background(Theme.warmOrange, in: Capsule())
.accessibilityLabel("Pro feature")
} }
} }

View File

@@ -35,6 +35,16 @@ final class PollVotingViewModel {
rankings.move(fromOffsets: source, toOffset: destination) rankings.move(fromOffsets: source, toOffset: destination)
} }
func moveTripUp(at index: Int) {
guard index > 0, index < rankings.count else { return }
rankings.swapAt(index, index - 1)
}
func moveTripDown(at index: Int) {
guard index >= 0, index < rankings.count - 1 else { return }
rankings.swapAt(index, index + 1)
}
func submitVote(pollId: UUID) async { func submitVote(pollId: UUID) async {
guard canSubmit else { return } guard canSubmit else { return }

View File

@@ -21,6 +21,7 @@ struct PollCreationView: View {
Section { Section {
TextField("Poll Title", text: $viewModel.title) TextField("Poll Title", text: $viewModel.title)
.textInputAutocapitalization(.words) .textInputAutocapitalization(.words)
.accessibilityHint("Enter a descriptive name for your poll")
} header: { } header: {
Text("Title") Text("Title")
} footer: { } footer: {
@@ -115,10 +116,13 @@ private struct TripSelectionRow: View {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title2) .font(.title2)
.foregroundStyle(isSelected ? Theme.warmOrange : .secondary) .foregroundStyle(isSelected ? Theme.warmOrange : .secondary)
.accessibilityHidden(true)
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
} }
private var tripSummary: String { private var tripSummary: String {

View File

@@ -77,6 +77,8 @@ struct PollDetailView: View {
} }
} label: { } label: {
Image(systemName: "ellipsis.circle") Image(systemName: "ellipsis.circle")
.minimumHitTarget()
.accessibilityLabel("More options")
} }
} }
} }
@@ -172,7 +174,7 @@ struct PollDetailView: View {
.fill(Theme.warmOrange.opacity(0.15)) .fill(Theme.warmOrange.opacity(0.15))
.frame(width: 56, height: 56) .frame(width: 56, height: 56)
Image(systemName: "link.circle.fill") Image(systemName: "link.circle.fill")
.font(.system(size: 28)) .font(.title2)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
} }
@@ -182,9 +184,11 @@ struct PollDetailView: View {
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
Text(poll.shareCode) Text(poll.shareCode)
.font(.system(size: 36, weight: .bold, design: .monospaced)) .font(.system(.largeTitle, design: .monospaced).weight(.bold))
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.tracking(4) .tracking(4)
.lineLimit(1)
.minimumScaleFactor(0.7)
} }
// Copy button // Copy button
@@ -221,6 +225,7 @@ struct PollDetailView: View {
Image(systemName: viewModel.hasVoted ? "checkmark.circle.fill" : "hand.raised.fill") Image(systemName: viewModel.hasVoted ? "checkmark.circle.fill" : "hand.raised.fill")
.font(.title3) .font(.title3)
.foregroundStyle(viewModel.hasVoted ? Theme.mlsGreen : Theme.warmOrange) .foregroundStyle(viewModel.hasVoted ? Theme.mlsGreen : Theme.warmOrange)
.accessibilityLabel(viewModel.hasVoted ? "You have voted" : "You have not voted")
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@@ -231,6 +236,7 @@ struct PollDetailView: View {
HStack(spacing: Theme.Spacing.xs) { HStack(spacing: Theme.Spacing.xs) {
Image(systemName: "person.2.fill") Image(systemName: "person.2.fill")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
Text("\(viewModel.votes.count) vote\(viewModel.votes.count == 1 ? "" : "s")") Text("\(viewModel.votes.count) vote\(viewModel.votes.count == 1 ? "" : "s")")
.font(.subheadline) .font(.subheadline)
} }
@@ -263,6 +269,7 @@ struct PollDetailView: View {
HStack(spacing: Theme.Spacing.sm) { HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "chart.bar.fill") Image(systemName: "chart.bar.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Results") Text("Results")
.font(.headline) .font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
@@ -294,6 +301,7 @@ struct PollDetailView: View {
HStack(spacing: Theme.Spacing.sm) { HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "map.fill") Image(systemName: "map.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Trip Options") Text("Trip Options")
.font(.headline) .font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
@@ -347,6 +355,27 @@ private struct ResultRow: View {
} }
} }
private var rankAccessibilityLabel: String {
switch rank {
case 1: return "First place"
case 2: return "Second place"
case 3: return "Third place"
default: return "\(rank)\(rankSuffix) place"
}
}
private var rankSuffix: String {
let ones = rank % 10
let tens = rank % 100
if tens >= 11 && tens <= 13 { return "th" }
switch ones {
case 1: return "st"
case 2: return "nd"
case 3: return "rd"
default: return "th"
}
}
private var rankColor: Color { private var rankColor: Color {
switch rank { switch rank {
case 1: return Theme.warmOrange case 1: return Theme.warmOrange
@@ -366,6 +395,7 @@ private struct ResultRow: View {
Image(systemName: rankIcon) Image(systemName: rankIcon)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(rankColor) .foregroundStyle(rankColor)
.accessibilityLabel(rankAccessibilityLabel)
} }
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -447,6 +477,7 @@ private struct TripPreviewCard: View {
.font(.caption) .font(.caption)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.accessibilityHidden(true)
} }
.padding() .padding()
.background(Theme.cardBackground(colorScheme)) .background(Theme.cardBackground(colorScheme))

View File

@@ -27,8 +27,13 @@ struct PollVotingView: View {
ForEach(Array(viewModel.rankings.enumerated()), id: \.element) { index, tripIndex in ForEach(Array(viewModel.rankings.enumerated()), id: \.element) { index, tripIndex in
RankingRow( RankingRow(
rank: index + 1, rank: index + 1,
trip: poll.tripSnapshots[tripIndex] trip: poll.tripSnapshots[tripIndex],
canMoveUp: index > 0,
canMoveDown: index < viewModel.rankings.count - 1,
onMoveUp: { viewModel.moveTripUp(at: index) },
onMoveDown: { viewModel.moveTripDown(at: index) }
) )
.accessibilityHint("Drag to change ranking position, or use move up and move down buttons")
} }
.onMove { source, destination in .onMove { source, destination in
viewModel.moveTrip(from: source, to: destination) viewModel.moveTrip(from: source, to: destination)
@@ -79,11 +84,12 @@ struct PollVotingView: View {
Image(systemName: "arrow.up.arrow.down") Image(systemName: "arrow.up.arrow.down")
.font(.title2) .font(.title2)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityLabel("Drag to reorder")
Text("Drag to rank your preferences") Text("Drag to rank your preferences")
.font(.headline) .font(.headline)
Text("Your top choice should be at the top") Text("Your top choice should be at the top. You can drag, or use the move buttons.")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -126,6 +132,10 @@ struct PollVotingView: View {
private struct RankingRow: View { private struct RankingRow: View {
let rank: Int let rank: Int
let trip: Trip let trip: Trip
let canMoveUp: Bool
let canMoveDown: Bool
let onMoveUp: () -> Void
let onMoveDown: () -> Void
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -147,6 +157,25 @@ private struct RankingRow: View {
} }
Spacer() Spacer()
VStack(spacing: 6) {
Button(action: onMoveUp) {
Image(systemName: "chevron.up")
.font(.caption.weight(.semibold))
}
.minimumHitTarget()
.disabled(!canMoveUp)
.accessibilityLabel("Move \(trip.displayName) up")
Button(action: onMoveDown) {
Image(systemName: "chevron.down")
.font(.caption.weight(.semibold))
}
.minimumHitTarget()
.disabled(!canMoveDown)
.accessibilityLabel("Move \(trip.displayName) down")
}
.foregroundStyle(.secondary)
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }

View File

@@ -33,6 +33,7 @@ struct PollsListView: View {
showJoinPoll = true showJoinPoll = true
} label: { } label: {
Image(systemName: "link.badge.plus") Image(systemName: "link.badge.plus")
.accessibilityLabel("Join a poll")
} }
} }
} }

View File

@@ -26,7 +26,7 @@ final class MapInteractionViewModel {
} }
func resetToDefault() { func resetToDefault() {
withAnimation(.easeInOut(duration: 0.5)) { Theme.Animation.withMotion(.easeInOut(duration: 0.5)) {
region = MapInteractionViewModel.defaultRegion region = MapInteractionViewModel.defaultRegion
} }
hasUserInteracted = false hasUserInteracted = false
@@ -34,7 +34,7 @@ final class MapInteractionViewModel {
} }
func zoomToStadium(at coordinate: CLLocationCoordinate2D) { func zoomToStadium(at coordinate: CLLocationCoordinate2D) {
withAnimation(.easeInOut(duration: 0.3)) { Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
region = MKCoordinateRegion( region = MKCoordinateRegion(
center: coordinate, center: coordinate,
span: MapInteractionViewModel.stadiumZoomSpan span: MapInteractionViewModel.stadiumZoomSpan

View File

@@ -115,14 +115,16 @@ struct AchievementsListView: View {
.frame(width: 64, height: 64) .frame(width: 64, height: 64)
Image(systemName: selectedSport?.iconName ?? "trophy.fill") Image(systemName: selectedSport?.iconName ?? "trophy.fill")
.font(.system(size: 28)) .font(.title2)
.foregroundStyle(earned > 0 ? completedGold : accentColor) .foregroundStyle(earned > 0 ? completedGold : accentColor)
.accessibilityHidden(true)
} }
VStack(alignment: .leading, spacing: Theme.Spacing.xs) { VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
HStack(alignment: .firstTextBaseline, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(earned)") Text("\(earned)")
.font(.system(size: 36, weight: .bold, design: .rounded)) .font(.system(.largeTitle, design: .rounded).weight(.bold))
.monospacedDigit()
.foregroundStyle(earned > 0 ? completedGold : Theme.textPrimary(colorScheme)) .foregroundStyle(earned > 0 ? completedGold : Theme.textPrimary(colorScheme))
Text("/ \(total)") Text("/ \(total)")
.font(.title2) .font(.title2)
@@ -174,7 +176,7 @@ struct AchievementsListView: View {
color: Theme.warmOrange, color: Theme.warmOrange,
isSelected: selectedSport == nil isSelected: selectedSport == nil
) { ) {
withAnimation(Theme.Animation.spring) { Theme.Animation.withMotion(Theme.Animation.spring) {
selectedSport = nil selectedSport = nil
} }
} }
@@ -187,7 +189,7 @@ struct AchievementsListView: View {
color: sport.themeColor, color: sport.themeColor,
isSelected: selectedSport == sport isSelected: selectedSport == sport
) { ) {
withAnimation(Theme.Animation.spring) { Theme.Animation.withMotion(Theme.Animation.spring) {
selectedSport = sport selectedSport = sport
} }
} }
@@ -287,6 +289,8 @@ struct SportFilterButton: View {
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
} }
} }
@@ -318,8 +322,9 @@ struct AchievementCard: View {
} }
Image(systemName: achievement.definition.iconName) Image(systemName: achievement.definition.iconName)
.font(.system(size: 28)) .font(.title2)
.foregroundStyle(badgeIconColor) .foregroundStyle(badgeIconColor)
.accessibilityHidden(true)
if !achievement.isEarned { if !achievement.isEarned {
Circle() Circle()
@@ -329,6 +334,7 @@ struct AchievementCard: View {
Image(systemName: "lock.fill") Image(systemName: "lock.fill")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.white) .foregroundStyle(.white)
.accessibilityHidden(true)
} }
} }
@@ -346,6 +352,7 @@ struct AchievementCard: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
.font(.caption) .font(.caption)
.accessibilityHidden(true)
if let earnedAt = achievement.earnedAt { if let earnedAt = achievement.earnedAt {
Text(earnedAt.formatted(date: .abbreviated, time: .omitted)) Text(earnedAt.formatted(date: .abbreviated, time: .omitted))
} else { } else {
@@ -376,6 +383,7 @@ struct AchievementCard: View {
} }
.shadow(color: achievement.isEarned ? completedGold.opacity(0.3) : Theme.cardShadow(colorScheme), radius: achievement.isEarned ? 8 : 5, y: 2) .shadow(color: achievement.isEarned ? completedGold.opacity(0.3) : Theme.cardShadow(colorScheme), radius: achievement.isEarned ? 8 : 5, y: 2)
.opacity(achievement.isEarned ? 1.0 : 0.7) .opacity(achievement.isEarned ? 1.0 : 0.7)
.accessibilityElement(children: .combine)
} }
private var badgeBackgroundColor: Color { private var badgeBackgroundColor: Color {
@@ -492,8 +500,9 @@ struct AchievementDetailSheet: View {
} }
Image(systemName: achievement.definition.iconName) Image(systemName: achievement.definition.iconName)
.font(.system(size: 56)) .font(.largeTitle)
.foregroundStyle(badgeIconColor) .foregroundStyle(badgeIconColor)
.accessibilityHidden(true)
if !achievement.isEarned { if !achievement.isEarned {
Circle() Circle()
@@ -501,8 +510,9 @@ struct AchievementDetailSheet: View {
.frame(width: 120, height: 120) .frame(width: 120, height: 120)
Image(systemName: "lock.fill") Image(systemName: "lock.fill")
.font(.system(size: 24)) .font(.title3)
.foregroundStyle(.white) .foregroundStyle(.white)
.accessibilityHidden(true)
} }
} }
@@ -538,8 +548,9 @@ struct AchievementDetailSheet: View {
if achievement.isEarned { if achievement.isEarned {
VStack(spacing: 8) { VStack(spacing: 8) {
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
.font(.system(size: 32)) .font(.title)
.foregroundStyle(completedGold) .foregroundStyle(completedGold)
.accessibilityHidden(true)
if let earnedAt = achievement.earnedAt { if let earnedAt = achievement.earnedAt {
Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))") Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))")
@@ -575,6 +586,7 @@ struct AchievementDetailSheet: View {
if let sport = achievement.definition.sport { if let sport = achievement.definition.sport {
HStack(spacing: Theme.Spacing.xs) { HStack(spacing: Theme.Spacing.xs) {
Image(systemName: sport.iconName) Image(systemName: sport.iconName)
.accessibilityLabel(sport.displayName)
Text(sport.displayName) Text(sport.displayName)
} }
.font(.subheadline) .font(.subheadline)

View File

@@ -12,6 +12,7 @@ struct GamesHistoryRow: View {
.font(.title3) .font(.title3)
.foregroundStyle(stadium.sport.themeColor) .foregroundStyle(stadium.sport.themeColor)
.frame(width: 32) .frame(width: 32)
.accessibilityHidden(true)
} }
// Visit info // Visit info
@@ -38,10 +39,12 @@ struct GamesHistoryRow: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.accessibilityHidden(true)
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
.accessibilityElement(children: .combine)
} }
private func sportIcon(for sport: Sport) -> String { private func sportIcon(for sport: Sport) -> String {

View File

@@ -8,7 +8,7 @@ struct VisitListCard: View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Header row (always visible) // Header row (always visible)
Button { Button {
withAnimation(.easeInOut(duration: 0.2)) { Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
isExpanded.toggle() isExpanded.toggle()
} }
} label: { } label: {
@@ -37,11 +37,13 @@ struct VisitListCard: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0)) .rotationEffect(.degrees(isExpanded ? 90 : 0))
.accessibilityLabel(isExpanded ? "Collapse details" : "Expand details")
} }
.padding() .padding()
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityHint("Double-tap to expand visit details")
// Expanded content // Expanded content
if isExpanded { if isExpanded {
@@ -115,6 +117,7 @@ private struct InfoRow: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 16) .frame(width: 16)
.accessibilityHidden(true)
Text(label) Text(label)
.font(.caption) .font(.caption)

View File

@@ -107,6 +107,7 @@ struct GameMatchConfirmationView: View {
HStack { HStack {
Image(systemName: "mappin.circle.fill") Image(systemName: "mappin.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Nearest Stadium") Text("Nearest Stadium")
.font(.body) .font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
@@ -131,6 +132,7 @@ struct GameMatchConfirmationView: View {
Text(match.formattedDistance) Text(match.formattedDistance)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(confidenceColor(match.confidence)) .foregroundStyle(confidenceColor(match.confidence))
.accessibilityLabel("\(match.formattedDistance), \(match.confidence.description) confidence")
Text(match.confidence.description) Text(match.confidence.description)
.font(.caption2) .font(.caption2)
@@ -154,6 +156,7 @@ struct GameMatchConfirmationView: View {
HStack { HStack {
Image(systemName: "sportscourt.fill") Image(systemName: "sportscourt.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text(matchOptionsTitle) Text(matchOptionsTitle)
.font(.body) .font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
@@ -196,6 +199,9 @@ struct GameMatchConfirmationView: View {
} label: { } label: {
gameMatchRow(match, isSelected: selectedMatch?.id == match.id) gameMatchRow(match, isSelected: selectedMatch?.id == match.id)
} }
.buttonStyle(.plain)
.accessibilityValue(selectedMatch?.id == match.id ? "Selected" : "Not selected")
.accessibilityAddTraits(selectedMatch?.id == match.id ? .isSelected : [])
} }
} }
@@ -222,6 +228,7 @@ struct GameMatchConfirmationView: View {
Image(systemName: match.game.sport.iconName) Image(systemName: match.game.sport.iconName)
.font(.caption) .font(.caption)
.foregroundStyle(match.game.sport.themeColor) .foregroundStyle(match.game.sport.themeColor)
.accessibilityHidden(true)
} }
Text(match.gameDateTime) Text(match.gameDateTime)
@@ -233,6 +240,7 @@ struct GameMatchConfirmationView: View {
Circle() Circle()
.fill(combinedConfidenceColor(match.confidence.combined)) .fill(combinedConfidenceColor(match.confidence.combined))
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
.accessibilityLabel(confidenceAccessibilityLabel(match.confidence.combined))
Text(match.confidence.combined.description) Text(match.confidence.combined.description)
.font(.caption2) .font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
@@ -245,6 +253,7 @@ struct GameMatchConfirmationView: View {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title2) .font(.title2)
.foregroundStyle(isSelected ? .green : Theme.textMuted(colorScheme)) .foregroundStyle(isSelected ? .green : Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(isSelected ? Theme.cardBackgroundElevated(colorScheme) : Color.clear) .background(isSelected ? Theme.cardBackgroundElevated(colorScheme) : Color.clear)
@@ -318,6 +327,14 @@ struct GameMatchConfirmationView: View {
case .manualOnly: return .red case .manualOnly: return .red
} }
} }
private func confidenceAccessibilityLabel(_ confidence: CombinedConfidence) -> String {
switch confidence {
case .autoSelect: return "High confidence"
case .userConfirm: return "Medium confidence"
case .manualOnly: return "Low confidence"
}
}
} }
// MARK: - Preview // MARK: - Preview

View File

@@ -57,6 +57,7 @@ private struct GamesHistoryContent: View {
viewModel.clearFilters() viewModel.clearFilters()
} }
.font(.caption) .font(.caption)
.accessibilityHint("Clear all sport filters")
} }
} }
@@ -119,6 +120,7 @@ private struct SportChip: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: sportIcon) Image(systemName: sportIcon)
.font(.caption) .font(.caption)
.accessibilityHidden(true)
Text(sport.rawValue) Text(sport.rawValue)
.font(.caption.bold()) .font(.caption.bold())
} }
@@ -131,6 +133,8 @@ private struct SportChip: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
} }
private var sportIcon: String { private var sportIcon: String {
@@ -199,7 +203,7 @@ private struct EmptyGamesView: View {
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: "ticket") Image(systemName: "ticket")
.font(.system(size: 48)) .font(.largeTitle)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("No games recorded yet") Text("No games recorded yet")

View File

@@ -91,7 +91,7 @@ struct PhotoImportView: View {
.frame(width: 120, height: 120) .frame(width: 120, height: 120)
Image(systemName: "photo.on.rectangle.angled") Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 50)) .font(.largeTitle)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
} }
@@ -137,6 +137,7 @@ struct PhotoImportView: View {
HStack { HStack {
Image(systemName: "info.circle.fill") Image(systemName: "info.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("How it works") Text("How it works")
.font(.body) .font(.body)
} }
@@ -374,6 +375,7 @@ struct PhotoImportCandidateCard: View {
.font(.title2) .font(.title2)
.foregroundStyle(isConfirmed ? .green : Theme.textMuted(colorScheme)) .foregroundStyle(isConfirmed ? .green : Theme.textMuted(colorScheme))
} }
.accessibilityLabel(isConfirmed ? "Deselect for import" : "Confirm import")
} }
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
@@ -419,6 +421,7 @@ struct PhotoImportCandidateCard: View {
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
} }
@@ -426,6 +429,7 @@ struct PhotoImportCandidateCard: View {
HStack { HStack {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.red) .foregroundStyle(.red)
.accessibilityLabel("Error")
Text(reason.description) Text(reason.description)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
@@ -538,6 +542,7 @@ private struct InfoRow: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: icon) Image(systemName: icon)
.frame(width: 16) .frame(width: 16)
.accessibilityHidden(true)
Text(text) Text(text)
} }
} }

View File

@@ -33,7 +33,7 @@ struct ProgressMapView: View {
isVisited: isVisited(stadium), isVisited: isVisited(stadium),
isSelected: selectedStadium?.id == stadium.id, isSelected: selectedStadium?.id == stadium.id,
onTap: { onTap: {
withAnimation(.spring(response: 0.3)) { Theme.Animation.withMotion(.spring(response: 0.3)) {
if selectedStadium?.id == stadium.id { if selectedStadium?.id == stadium.id {
selectedStadium = nil selectedStadium = nil
} else { } else {
@@ -51,7 +51,7 @@ struct ProgressMapView: View {
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
if mapViewModel.shouldShowResetButton { if mapViewModel.shouldShowResetButton {
Button { Button {
withAnimation(.easeInOut(duration: 0.5)) { Theme.Animation.withMotion(.easeInOut(duration: 0.5)) {
cameraPosition = .region(MapInteractionViewModel.defaultRegion) cameraPosition = .region(MapInteractionViewModel.defaultRegion)
mapViewModel.resetToDefault() mapViewModel.resetToDefault()
selectedStadium = nil selectedStadium = nil
@@ -108,6 +108,7 @@ struct StadiumMapPin: View {
.fill(pinColor) .fill(pinColor)
.frame(width: 10, height: 6) .frame(width: 10, height: 6)
.offset(y: -2) .offset(y: -2)
.accessibilityHidden(true)
// Stadium name (when selected) // Stadium name (when selected)
if isSelected { if isSelected {
@@ -128,7 +129,10 @@ struct StadiumMapPin: View {
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.animation(.spring(response: 0.3), value: isSelected) .accessibilityLabel("\(stadium.name), \(isVisited ? "visited" : "not visited")")
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
.animation(Theme.Animation.prefersReducedMotion ? nil : .spring(response: 0.3), value: isSelected)
} }
private var pinColor: Color { private var pinColor: Color {

View File

@@ -63,6 +63,7 @@ struct ProgressTabView: View {
.font(.title2) .font(.title2)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
} }
.accessibilityLabel("Add stadium visit")
} }
} }
.task { .task {
@@ -153,7 +154,7 @@ struct ProgressTabView: View {
isSelected: viewModel.selectedSport == sport, isSelected: viewModel.selectedSport == sport,
progress: progressForSport(sport) progress: progressForSport(sport)
) { ) {
withAnimation(Theme.Animation.spring) { Theme.Animation.withMotion(Theme.Animation.spring) {
viewModel.selectSport(sport) viewModel.selectSport(sport)
} }
} }
@@ -180,13 +181,18 @@ struct ProgressTabView: View {
Circle() Circle()
.stroke(Theme.warmOrange.opacity(0.2), lineWidth: 8) .stroke(Theme.warmOrange.opacity(0.2), lineWidth: 8)
.frame(width: 80, height: 80) .frame(width: 80, height: 80)
.accessibilityHidden(true)
Circle() Circle()
.trim(from: 0, to: progress.progressFraction) .trim(from: 0, to: progress.progressFraction)
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round)) .stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.frame(width: 80, height: 80) .frame(width: 80, height: 80)
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.5), value: progress.progressFraction) .animation(
Theme.Animation.prefersReducedMotion ? nil : .easeInOut(duration: 0.5),
value: progress.progressFraction
)
.accessibilityHidden(true)
VStack(spacing: 0) { VStack(spacing: 0) {
Text("\(progress.visitedStadiums)") Text("\(progress.visitedStadiums)")
@@ -265,6 +271,7 @@ struct ProgressTabView: View {
HStack { HStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green) .foregroundStyle(.green)
.accessibilityHidden(true)
Text("Visited (\(viewModel.visitedStadiums.count))") Text("Visited (\(viewModel.visitedStadiums.count))")
.font(.body) .font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
@@ -292,6 +299,7 @@ struct ProgressTabView: View {
HStack { HStack {
Image(systemName: "circle.dotted") Image(systemName: "circle.dotted")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
Text("Not Yet Visited (\(viewModel.unvisitedStadiums.count))") Text("Not Yet Visited (\(viewModel.unvisitedStadiums.count))")
.font(.body) .font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
@@ -331,6 +339,7 @@ struct ProgressTabView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Text("View All") Text("View All")
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.accessibilityHidden(true)
} }
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
@@ -348,8 +357,9 @@ struct ProgressTabView: View {
.frame(width: 50, height: 50) .frame(width: 50, height: 50)
Image(systemName: "trophy.fill") Image(systemName: "trophy.fill")
.font(.system(size: 24)) .font(.title3)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -366,6 +376,7 @@ struct ProgressTabView: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme)) .background(Theme.cardBackground(colorScheme))
@@ -396,6 +407,7 @@ struct ProgressTabView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Text("See All") Text("See All")
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.accessibilityHidden(true)
} }
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
@@ -432,6 +444,7 @@ struct ProgressStatPill: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: icon) Image(systemName: icon)
.font(.caption) .font(.caption)
.accessibilityHidden(true)
Text(value) Text(value)
.font(.body) .font(.body)
} }
@@ -460,6 +473,7 @@ struct StadiumChip: View {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.font(.caption) .font(.caption)
.foregroundStyle(.green) .foregroundStyle(.green)
.accessibilityHidden(true)
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@@ -495,6 +509,7 @@ struct StadiumChip: View {
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityElement(children: .combine)
} }
} }
@@ -513,6 +528,7 @@ struct RecentVisitRow: View {
Image(systemName: visit.sport.iconName) Image(systemName: visit.sport.iconName)
.foregroundStyle(visit.sport.themeColor) .foregroundStyle(visit.sport.themeColor)
.accessibilityHidden(true)
} }
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -538,6 +554,7 @@ struct RecentVisitRow: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme)) .background(Theme.cardBackground(colorScheme))
@@ -546,6 +563,7 @@ struct RecentVisitRow: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
} }
.accessibilityElement(children: .combine)
} }
} }

View File

@@ -34,6 +34,7 @@ struct StadiumVisitHistoryView: View {
} label: { } label: {
Image(systemName: "plus") Image(systemName: "plus")
} }
.accessibilityLabel("Add visit to this stadium")
} }
} }
.sheet(isPresented: $showingAddVisit) { .sheet(isPresented: $showingAddVisit) {
@@ -93,7 +94,7 @@ private struct EmptyVisitHistoryView: View {
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: "calendar.badge.plus") Image(systemName: "calendar.badge.plus")
.font(.system(size: 48)) .font(.largeTitle)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("No visits recorded") Text("No visits recorded")

View File

@@ -165,6 +165,7 @@ struct StadiumVisitSheet: View {
.background(Theme.cardBackgroundElevated(colorScheme)) .background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(Capsule()) .clipShape(Capsule())
} }
.accessibilityLabel("Select team \(team.name)")
} }
} }
.padding(.top, 8) .padding(.top, 8)
@@ -201,6 +202,7 @@ struct StadiumVisitSheet: View {
.background(Theme.cardBackgroundElevated(colorScheme)) .background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(Capsule()) .clipShape(Capsule())
} }
.accessibilityLabel("Select team \(team.name)")
} }
} }
.padding(.top, 8) .padding(.top, 8)
@@ -283,6 +285,7 @@ struct StadiumVisitSheet: View {
HStack { HStack {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange) .foregroundStyle(.orange)
.accessibilityHidden(true)
Text(error) Text(error)
.foregroundStyle(.red) .foregroundStyle(.red)
} }

View File

@@ -149,6 +149,7 @@ struct VisitDetailView: View {
Image(systemName: visit.sportEnum?.iconName ?? "sportscourt") Image(systemName: visit.sportEnum?.iconName ?? "sportscourt")
.font(.largeTitle) .font(.largeTitle)
.foregroundStyle(sportColor) .foregroundStyle(sportColor)
.accessibilityLabel(visit.sportEnum?.displayName ?? "Sport")
} }
VStack(spacing: Theme.Spacing.xs) { VStack(spacing: Theme.Spacing.xs) {
@@ -188,6 +189,7 @@ struct VisitDetailView: View {
HStack { HStack {
Image(systemName: "sportscourt.fill") Image(systemName: "sportscourt.fill")
.foregroundStyle(sportColor) .foregroundStyle(sportColor)
.accessibilityHidden(true)
Text("Game Info") Text("Game Info")
.font(.body) .font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
@@ -232,6 +234,7 @@ struct VisitDetailView: View {
HStack { HStack {
Image(systemName: "info.circle.fill") Image(systemName: "info.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Details") Text("Details")
.font(.body) .font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
@@ -292,6 +295,7 @@ struct VisitDetailView: View {
HStack { HStack {
Image(systemName: "note.text") Image(systemName: "note.text")
.foregroundStyle(Theme.routeGold) .foregroundStyle(Theme.routeGold)
.accessibilityHidden(true)
Text("Notes") Text("Notes")
.font(.body) .font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))

View File

@@ -56,6 +56,7 @@ struct ScheduleListView: View {
} }
} label: { } label: {
Image(systemName: viewModel.hasFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") Image(systemName: viewModel.hasFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
.accessibilityLabel(viewModel.hasFilters ? "Filter options, filters active" : "Filter options")
} }
} }
} }
@@ -116,6 +117,7 @@ struct ScheduleListView: View {
} header: { } header: {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: sportGroup.sport.iconName) Image(systemName: sportGroup.sport.iconName)
.accessibilityHidden(true)
Text(sportGroup.sport.rawValue) Text(sportGroup.sport.rawValue)
} }
.font(.headline) .font(.headline)
@@ -219,6 +221,8 @@ struct SportFilterChip: View {
.clipShape(Capsule()) .clipShape(Capsule())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
} }
} }
@@ -297,6 +301,7 @@ struct GameRowView: View {
Image(systemName: "mappin.circle.fill") Image(systemName: "mappin.circle.fill")
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.accessibilityHidden(true)
Text(game.stadium.city) Text(game.stadium.city)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -320,6 +325,7 @@ struct TeamBadge: View {
Circle() Circle()
.fill(Color(hex: colorHex)) .fill(Color(hex: colorHex))
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
.accessibilityHidden(true)
} }
Text(team.abbreviation) Text(team.abbreviation)

View File

@@ -96,7 +96,7 @@ struct SettingsView: View {
Section { Section {
ForEach(AppearanceMode.allCases) { mode in ForEach(AppearanceMode.allCases) { mode in
Button { Button {
withAnimation(.easeInOut(duration: 0.2)) { Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
AppearanceManager.shared.currentMode = mode AppearanceManager.shared.currentMode = mode
AnalyticsManager.shared.track(.appearanceChanged(mode: mode.displayName)) AnalyticsManager.shared.track(.appearanceChanged(mode: mode.displayName))
} }
@@ -109,8 +109,9 @@ struct SettingsView: View {
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
Image(systemName: mode.iconName) Image(systemName: mode.iconName)
.font(.system(size: 16)) .font(.body)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@@ -128,11 +129,13 @@ struct SettingsView: View {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.font(.title3) .font(.title3)
.accessibilityHidden(true)
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityAddTraits(AppearanceManager.shared.currentMode == mode ? .isSelected : [])
} }
} header: { } header: {
Text("Appearance") Text("Appearance")
@@ -148,7 +151,7 @@ struct SettingsView: View {
Section { Section {
ForEach(AppTheme.allCases) { theme in ForEach(AppTheme.allCases) { theme in
Button { Button {
withAnimation(.easeInOut(duration: 0.2)) { Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
viewModel.selectedTheme = theme viewModel.selectedTheme = theme
} }
} label: { } label: {
@@ -181,11 +184,13 @@ struct SettingsView: View {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.font(.title3) .font(.title3)
.accessibilityHidden(true)
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityAddTraits(viewModel.selectedTheme == theme ? .isSelected : [])
} }
} header: { } header: {
Text("Theme") Text("Theme")
@@ -218,6 +223,7 @@ struct SettingsView: View {
} icon: { } icon: {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
} }
} header: { } header: {
@@ -306,6 +312,7 @@ struct SettingsView: View {
} icon: { } icon: {
Image(systemName: "chart.bar.xaxis") Image(systemName: "chart.bar.xaxis")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
} }
} header: { } header: {
@@ -328,15 +335,30 @@ struct SettingsView: View {
} }
Link(destination: URL(string: "https://sportstime.88oakapps.com/privacy.html")!) { Link(destination: URL(string: "https://sportstime.88oakapps.com/privacy.html")!) {
Label("Privacy Policy", systemImage: "hand.raised") Label {
Text("Privacy Policy")
} icon: {
Image(systemName: "hand.raised")
.accessibilityHidden(true)
}
} }
Link(destination: URL(string: "https://sportstime.88oakapps.com/eula.html")!) { Link(destination: URL(string: "https://sportstime.88oakapps.com/eula.html")!) {
Label("EULA", systemImage: "doc.text") Label {
Text("EULA")
} icon: {
Image(systemName: "doc.text")
.accessibilityHidden(true)
}
} }
Link(destination: URL(string: "mailto:support@88oakapps.com")!) { Link(destination: URL(string: "mailto:support@88oakapps.com")!) {
Label("Contact Support", systemImage: "envelope") Label {
Text("Contact Support")
} icon: {
Image(systemName: "envelope")
.accessibilityHidden(true)
}
} }
} header: { } header: {
Text("About") Text("About")
@@ -365,6 +387,7 @@ struct SettingsView: View {
} icon: { } icon: {
Image(systemName: "photo.badge.plus") Image(systemName: "photo.badge.plus")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
} }
} header: { } header: {
@@ -380,7 +403,12 @@ struct SettingsView: View {
Button(role: .destructive) { Button(role: .destructive) {
showResetConfirmation = true showResetConfirmation = true
} label: { } label: {
Label("Reset to Defaults", systemImage: "arrow.counterclockwise") Label {
Text("Reset to Defaults")
} icon: {
Image(systemName: "arrow.counterclockwise")
.accessibilityHidden(true)
}
} }
} }
.listRowBackground(Theme.cardBackground(colorScheme)) .listRowBackground(Theme.cardBackground(colorScheme))
@@ -682,6 +710,7 @@ struct SettingsView: View {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green) .foregroundStyle(.green)
.accessibilityHidden(true)
} }
Button { Button {
@@ -714,6 +743,7 @@ struct SettingsView: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -741,6 +771,7 @@ struct SettingsView: View {
guard !isSyncActionInProgress else { return } guard !isSyncActionInProgress else { return }
isSyncActionInProgress = true isSyncActionInProgress = true
AccessibilityAnnouncer.announce("Manual sync started.")
Task { Task {
defer { isSyncActionInProgress = false } defer { isSyncActionInProgress = false }
@@ -748,8 +779,10 @@ struct SettingsView: View {
do { do {
let result = try await BackgroundSyncManager.shared.triggerManualSync() let result = try await BackgroundSyncManager.shared.triggerManualSync()
syncActionMessage = "Sync complete. Updated \(result.totalUpdated) records." syncActionMessage = "Sync complete. Updated \(result.totalUpdated) records."
AccessibilityAnnouncer.announce(syncActionMessage ?? "")
} catch { } catch {
syncActionMessage = "Sync failed: \(error.localizedDescription)" syncActionMessage = "Sync failed: \(error.localizedDescription)"
AccessibilityAnnouncer.announce(syncActionMessage ?? "")
} }
} }
} }
@@ -779,13 +812,13 @@ struct SyncStatusRow: View {
// Status indicator // Status indicator
Image(systemName: statusIcon) Image(systemName: statusIcon)
.foregroundStyle(statusColor) .foregroundStyle(statusColor)
.font(.system(size: 14)) .font(.subheadline)
.frame(width: 20) .frame(width: 20)
// Entity icon and name // Entity icon and name
Image(systemName: status.entityType.iconName) Image(systemName: status.entityType.iconName)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.font(.system(size: 14)) .font(.subheadline)
.frame(width: 20) .frame(width: 20)
Text(status.entityType.rawValue) Text(status.entityType.rawValue)
@@ -812,6 +845,7 @@ struct SyncStatusRow: View {
.foregroundStyle(.blue) .foregroundStyle(.blue)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("View sync details")
} }
} }

View File

@@ -29,7 +29,7 @@ struct CategoryPicker: View {
isSelected: selectedCategory == category, isSelected: selectedCategory == category,
colorScheme: colorScheme colorScheme: colorScheme
) { ) {
withAnimation(Theme.Animation.spring) { Theme.Animation.withMotion(Theme.Animation.spring) {
selectedCategory = category selectedCategory = category
} }
} }
@@ -131,7 +131,7 @@ private struct CategoryPillButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label configuration.label
.scaleEffect(configuration.isPressed ? 0.95 : 1.0) .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: configuration.isPressed) .animation(Theme.Animation.prefersReducedMotion ? nil : .spring(response: 0.3, dampingFraction: 0.7), value: configuration.isPressed)
} }
} }

View File

@@ -64,6 +64,7 @@ struct PlaceSearchSheet: View {
HStack(spacing: Theme.Spacing.sm) { HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
TextField(searchPlaceholder, text: $searchQuery) TextField(searchPlaceholder, text: $searchQuery)
.textFieldStyle(.plain) .textFieldStyle(.plain)
@@ -82,6 +83,7 @@ struct PlaceSearchSheet: View {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
} }
.minimumHitTarget()
.accessibilityLabel("Clear search") .accessibilityLabel("Clear search")
} }
} }
@@ -148,6 +150,7 @@ struct PlaceSearchSheet: View {
Image(systemName: "mappin.slash") Image(systemName: "mappin.slash")
.font(.largeTitle) .font(.largeTitle)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
Text("No places found") Text("No places found")
.font(.headline) .font(.headline)
@@ -180,6 +183,7 @@ struct PlaceSearchSheet: View {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.largeTitle) .font(.largeTitle)
.foregroundStyle(.orange) .foregroundStyle(.orange)
.accessibilityHidden(true)
Text("Search unavailable") Text("Search unavailable")
.font(.headline) .font(.headline)

View File

@@ -87,7 +87,7 @@ struct QuickAddItemSheet: View {
} }
.sheet(isPresented: $showLocationSearch) { .sheet(isPresented: $showLocationSearch) {
PlaceSearchSheet(category: selectedCategory) { place in PlaceSearchSheet(category: selectedCategory) { place in
withAnimation(Theme.Animation.spring) { Theme.Animation.withMotion(Theme.Animation.spring) {
selectedPlace = place selectedPlace = place
} }
// Use place name as title if empty // Use place name as title if empty
@@ -209,6 +209,7 @@ struct QuickAddItemSheet: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(inputBackground) .background(inputBackground)
@@ -255,7 +256,7 @@ struct QuickAddItemSheet: View {
// Remove button // Remove button
Button { Button {
withAnimation(Theme.Animation.spring) { Theme.Animation.withMotion(Theme.Animation.spring) {
selectedPlace = nil selectedPlace = nil
} }
} label: { } label: {
@@ -263,7 +264,9 @@ struct QuickAddItemSheet: View {
.font(.title3) .font(.title3)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
} }
.accessibilityLabel("Remove location") .minimumHitTarget()
.accessibilityLabel("Remove \(place.name ?? "location")")
.accessibilityHint("Double-tap to remove this location from the item")
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(Theme.warmOrange.opacity(0.08)) .background(Theme.warmOrange.opacity(0.08))
@@ -272,9 +275,6 @@ struct QuickAddItemSheet: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.strokeBorder(Theme.warmOrange.opacity(0.3), lineWidth: 1) .strokeBorder(Theme.warmOrange.opacity(0.3), lineWidth: 1)
) )
.accessibilityElement(children: .combine)
.accessibilityLabel("\(place.name ?? "Location"), \(formatAddress(for: place) ?? "")")
.accessibilityHint("Double-tap the remove button to clear this location")
} }
// MARK: - Section Header // MARK: - Section Header
@@ -284,6 +284,7 @@ struct QuickAddItemSheet: View {
Image(systemName: icon) Image(systemName: icon)
.font(.caption) .font(.caption)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text(title) Text(title)
.font(.subheadline) .font(.subheadline)
@@ -440,7 +441,10 @@ private struct PressableStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1.0) .scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: configuration.isPressed) .animation(
Theme.Animation.prefersReducedMotion ? nil : .spring(response: 0.3, dampingFraction: 0.7),
value: configuration.isPressed
)
} }
} }

View File

@@ -138,6 +138,7 @@ struct AddItemSheet: View {
HStack { HStack {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.accessibilityHidden(true)
TextField("Search for a place...", text: $searchQuery) TextField("Search for a place...", text: $searchQuery)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.autocorrectionDisabled() .autocorrectionDisabled()
@@ -156,6 +157,8 @@ struct AddItemSheet: View {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.minimumHitTarget()
.accessibilityLabel("Clear search")
} }
} }
.padding(10) .padding(10)
@@ -373,6 +376,7 @@ private struct PlaceResultRow: View {
if isSelected { if isSelected {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green) .foregroundStyle(.green)
.accessibilityHidden(true)
} }
} }
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -380,6 +384,8 @@ private struct PlaceResultRow: View {
.background(isSelected ? Color.green.opacity(0.1) : Color.clear) .background(isSelected ? Color.green.opacity(0.1) : Color.clear)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
} }
private var formattedAddress: String? { private var formattedAddress: String? {
@@ -422,6 +428,8 @@ private struct CategoryButton: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
} }
} }

View File

@@ -25,11 +25,13 @@ struct CustomItemRow: View {
Image(systemName: "line.3.horizontal") Image(systemName: "line.3.horizontal")
.font(.title3) .font(.title3)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityLabel("Drag to reorder")
// Icon and Title // Icon and Title
if let info = customInfo { if let info = customInfo {
Text(info.icon) Text(info.icon)
.font(.title3) .font(.title3)
.accessibilityHidden(true)
Text(info.title) Text(info.title)
.font(.body) .font(.body)
@@ -47,6 +49,7 @@ struct CustomItemRow: View {
} }
.padding(.vertical, Theme.Spacing.sm) .padding(.vertical, Theme.Spacing.sm)
.padding(.horizontal, Theme.Spacing.md) .padding(.horizontal, Theme.Spacing.md)
.accessibilityElement(children: .combine)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }

View File

@@ -34,6 +34,8 @@ struct DayHeaderRow: View {
.font(.title2) .font(.title2)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
} }
.minimumHitTarget()
.accessibilityLabel("Add item to this day")
} }
if isEmpty { if isEmpty {

View File

@@ -24,6 +24,7 @@ struct GameItemRow: View {
HStack(spacing: 3) { HStack(spacing: 3) {
Image(systemName: game.game.sport.iconName) Image(systemName: game.game.sport.iconName)
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
Text(game.game.sport.rawValue) Text(game.game.sport.rawValue)
.font(.caption2) .font(.caption2)
} }
@@ -44,6 +45,7 @@ struct GameItemRow: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "building.2") Image(systemName: "building.2")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
Text(game.stadium.name) Text(game.stadium.name)
.font(.subheadline) .font(.subheadline)
} }
@@ -57,6 +59,7 @@ struct GameItemRow: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
} }
.accessibilityElement(children: .combine)
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme)) .background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))

View File

@@ -24,6 +24,7 @@ struct TravelItemRow: View {
Image(systemName: "line.3.horizontal") Image(systemName: "line.3.horizontal")
.font(.title3) .font(.title3)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityLabel("Drag to reorder")
// Car icon // Car icon
ZStack { ZStack {
@@ -35,12 +36,14 @@ struct TravelItemRow: View {
.font(.body) .font(.body)
.foregroundStyle(Theme.routeGold) .foregroundStyle(Theme.routeGold)
} }
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
if let info = travelInfo { if let info = travelInfo {
Text("\(info.fromCity) \u{2192} \(info.toCity)") Text("\(info.fromCity) \u{2192} \(info.toCity)")
.font(.body) .font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
.accessibilityLabel("\(info.fromCity) to \(info.toCity)")
HStack(spacing: Theme.Spacing.xs) { HStack(spacing: Theme.Spacing.xs) {
if !info.formattedDistance.isEmpty { if !info.formattedDistance.isEmpty {
@@ -49,6 +52,7 @@ struct TravelItemRow: View {
} }
if !info.formattedDistance.isEmpty && !info.formattedDuration.isEmpty { if !info.formattedDistance.isEmpty && !info.formattedDuration.isEmpty {
Text("\u{2022}") Text("\u{2022}")
.accessibilityHidden(true)
} }
if !info.formattedDuration.isEmpty { if !info.formattedDuration.isEmpty {
Text(info.formattedDuration) Text(info.formattedDuration)
@@ -61,6 +65,7 @@ struct TravelItemRow: View {
Spacer() Spacer()
} }
.accessibilityElement(children: .combine)
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme)) .background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))

View File

@@ -1145,6 +1145,7 @@ struct GameRowCompact: View {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: "building.2") Image(systemName: "building.2")
.font(.caption) .font(.caption)
.accessibilityHidden(true)
Text(richGame.stadium.name) Text(richGame.stadium.name)
.font(.subheadline) .font(.subheadline)
} }
@@ -1175,6 +1176,7 @@ struct GameRowCompact: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Open \(richGame.stadium.name) in Maps") .accessibilityLabel("Open \(richGame.stadium.name) in Maps")
.accessibilityHint("Opens this stadium location in Apple Maps")
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme)) .background(Theme.cardBackgroundElevated(colorScheme))
@@ -1255,6 +1257,7 @@ struct TravelRowView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Get directions from \(segment.fromLocation.name) to \(segment.toLocation.name)") .accessibilityLabel("Get directions from \(segment.fromLocation.name) to \(segment.toLocation.name)")
.accessibilityHint("Opens this stadium location in Apple Maps")
} }
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
@@ -1304,6 +1307,7 @@ struct CustomItemRowView: View {
Image(systemName: "mappin.circle.fill") Image(systemName: "mappin.circle.fill")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
} }
@@ -1336,12 +1340,14 @@ struct CustomItemRowView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel("Open \(info.title) in Maps") .accessibilityLabel("Open \(info.title) in Maps")
.accessibilityHint("Opens this stadium location in Apple Maps")
} }
// Chevron indicates this is tappable // Chevron indicates this is tappable
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.font(.caption) .font(.caption)
.accessibilityHidden(true)
} }
.padding(.horizontal, Theme.Spacing.md) .padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm) .padding(.vertical, Theme.Spacing.sm)

View File

@@ -66,12 +66,36 @@ struct RegionMapSelector: View {
HStack(spacing: 0) { HStack(spacing: 0) {
Button { onToggle(.west) } label: { Color.clear } Button { onToggle(.west) } label: { Color.clear }
.accessibilityIdentifier("wizard.regions.west") .accessibilityIdentifier("wizard.regions.west")
.accessibilityLabel("West region")
.accessibilityValue(selectedRegions.contains(.west) ? "Selected" : "Not selected")
.accessibilityHint(
selectedRegions.contains(.west)
? "Double-tap to deselect this region"
: "Double-tap to select this region"
)
.accessibilityAddTraits(selectedRegions.contains(.west) ? .isSelected : [])
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
Button { onToggle(.central) } label: { Color.clear } Button { onToggle(.central) } label: { Color.clear }
.accessibilityIdentifier("wizard.regions.central") .accessibilityIdentifier("wizard.regions.central")
.accessibilityLabel("Central region")
.accessibilityValue(selectedRegions.contains(.central) ? "Selected" : "Not selected")
.accessibilityHint(
selectedRegions.contains(.central)
? "Double-tap to deselect this region"
: "Double-tap to select this region"
)
.accessibilityAddTraits(selectedRegions.contains(.central) ? .isSelected : [])
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
Button { onToggle(.east) } label: { Color.clear } Button { onToggle(.east) } label: { Color.clear }
.accessibilityIdentifier("wizard.regions.east") .accessibilityIdentifier("wizard.regions.east")
.accessibilityLabel("East region")
.accessibilityValue(selectedRegions.contains(.east) ? "Selected" : "Not selected")
.accessibilityHint(
selectedRegions.contains(.east)
? "Double-tap to deselect this region"
: "Double-tap to select this region"
)
.accessibilityAddTraits(selectedRegions.contains(.east) ? .isSelected : [])
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
@@ -166,6 +190,7 @@ struct RegionMapSelector: View {
Circle() Circle()
.stroke(Color.white.opacity(0.5), lineWidth: isSelected ? 2 : 0) .stroke(Color.white.opacity(0.5), lineWidth: isSelected ? 2 : 0)
) )
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text(region.shortName) Text(region.shortName)

View File

@@ -44,6 +44,7 @@ struct TeamPickerView: View {
HStack { HStack {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
TextField("Search teams...", text: $searchText) TextField("Search teams...", text: $searchText)
.textFieldStyle(.plain) .textFieldStyle(.plain)
@@ -55,6 +56,8 @@ struct TeamPickerView: View {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
} }
.minimumHitTarget()
.accessibilityLabel("Clear search")
} }
} }
.padding(Theme.Spacing.sm) .padding(Theme.Spacing.sm)
@@ -76,7 +79,7 @@ struct TeamPickerView: View {
Spacer() Spacer()
Button("Clear all") { Button("Clear all") {
withAnimation(Theme.Animation.spring) { Theme.Animation.withMotion(Theme.Animation.spring) {
selectedTeamIds.removeAll() selectedTeamIds.removeAll()
} }
} }
@@ -102,7 +105,7 @@ struct TeamPickerView: View {
} }
private func toggleTeam(_ team: Team) { private func toggleTeam(_ team: Team) {
withAnimation(Theme.Animation.spring) { Theme.Animation.withMotion(Theme.Animation.spring) {
if selectedTeamIds.contains(team.id) { if selectedTeamIds.contains(team.id) {
selectedTeamIds.remove(team.id) selectedTeamIds.remove(team.id)
} else { } else {
@@ -139,6 +142,7 @@ private struct TeamCard: View {
.font(.title3) .font(.title3)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundStyle(.white) .foregroundStyle(.white)
.accessibilityHidden(true)
} }
} }
@@ -167,6 +171,8 @@ private struct TeamCard: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
} }
private var teamColor: Color { private var teamColor: Color {

View File

@@ -74,31 +74,34 @@ struct TimelineItemView: View {
@ViewBuilder @ViewBuilder
private var itemIcon: some View { private var itemIcon: some View {
switch item { Group {
case .stop(let stop): switch item {
if stop.hasGames { case .stop(let stop):
Image(systemName: "sportscourt.fill") if stop.hasGames {
Image(systemName: "sportscourt.fill")
.foregroundStyle(.white)
.frame(width: 32, height: 32)
.background(Circle().fill(.blue))
} else {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(.orange)
.font(.title2)
}
case .travel(let segment):
Image(systemName: segment.travelMode == .drive ? "car.fill" : "airplane")
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(width: 32, height: 32) .frame(width: 28, height: 28)
.background(Circle().fill(.blue)) .background(Circle().fill(.green))
} else {
Image(systemName: "mappin.circle.fill") case .rest:
.foregroundStyle(.orange) Image(systemName: "bed.double.fill")
.font(.title2) .foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(Circle().fill(.purple))
} }
case .travel(let segment):
Image(systemName: segment.travelMode == .drive ? "car.fill" : "airplane")
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(Circle().fill(.green))
case .rest:
Image(systemName: "bed.double.fill")
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(Circle().fill(.purple))
} }
.accessibilityHidden(true)
} }
// MARK: - Item Content // MARK: - Item Content
@@ -178,30 +181,34 @@ struct TravelItemContent: View {
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
Text("") Text("\u{2022}")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.accessibilityHidden(true)
Text(segment.formattedDistance) Text(segment.formattedDistance)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("") Text("\u{2022}")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.accessibilityHidden(true)
Text(segment.formattedDuration) Text(segment.formattedDuration)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Text("\(segment.fromLocation.name) \(segment.toLocation.name)") Text("\(segment.fromLocation.name) \u{2192} \(segment.toLocation.name)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.accessibilityLabel("\(segment.fromLocation.name) to \(segment.toLocation.name)")
// EV Charging stops if applicable // EV Charging stops if applicable
if !segment.evChargingStops.isEmpty { if !segment.evChargingStops.isEmpty {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "bolt.fill") Image(systemName: "bolt.fill")
.foregroundStyle(.green) .foregroundStyle(.green)
.accessibilityHidden(true)
Text("\(segment.evChargingStops.count) charging stop(s)") Text("\(segment.evChargingStops.count) charging stop(s)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -263,6 +270,7 @@ struct TimelineGameRow: View {
Image(systemName: richGame.game.sport.iconName) Image(systemName: richGame.game.sport.iconName)
.foregroundStyle(richGame.game.sport.color) .foregroundStyle(richGame.game.sport.color)
.frame(width: 20) .frame(width: 20)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
// Matchup // Matchup
@@ -273,7 +281,8 @@ struct TimelineGameRow: View {
// Time and venue (stadium local time) // Time and venue (stadium local time)
HStack(spacing: 4) { HStack(spacing: 4) {
Text(richGame.localGameTimeShort) Text(richGame.localGameTimeShort)
Text("") Text("\u{2022}")
.accessibilityHidden(true)
Text(richGame.stadium.name) Text(richGame.stadium.name)
} }
.font(.caption) .font(.caption)
@@ -282,6 +291,7 @@ struct TimelineGameRow: View {
Spacer() Spacer()
} }
.accessibilityElement(children: .combine)
.padding(.vertical, 4) .padding(.vertical, 4)
} }
} }

View File

@@ -169,6 +169,7 @@ struct TripDetailView: View {
} }
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
} }
.accessibilityLabel("Export trip as PDF")
} }
} }
@@ -304,7 +305,10 @@ struct TripDetailView: View {
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round)) .stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.frame(width: 80, height: 80) .frame(width: 80, height: 80)
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.3), value: exportProgress?.percentComplete) .animation(
Theme.Animation.prefersReducedMotion ? nil : .easeInOut(duration: 0.3),
value: exportProgress?.percentComplete
)
Image(systemName: "doc.fill") Image(systemName: "doc.fill")
.font(.title2) .font(.title2)
@@ -363,6 +367,7 @@ struct TripDetailView: View {
.shadow(color: .black.opacity(0.2), radius: 4, y: 2) .shadow(color: .black.opacity(0.2), radius: 4, y: 2)
} }
.accessibilityIdentifier("tripDetail.favoriteButton") .accessibilityIdentifier("tripDetail.favoriteButton")
.accessibilityLabel(isSaved ? "Remove from favorites" : "Save to favorites")
.padding(.top, 12) .padding(.top, 12)
.padding(.trailing, 12) .padding(.trailing, 12)
} }
@@ -556,7 +561,7 @@ struct TripDetailView: View {
set: { targeted in set: { targeted in
// Only show as target if it's a valid drop location // Only show as target if it's a valid drop location
let shouldShowTarget = targeted && (draggedTravelId == nil || isValidTravelTarget) let shouldShowTarget = targeted && (draggedTravelId == nil || isValidTravelTarget)
withAnimation(.easeInOut(duration: 0.2)) { Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
if shouldShowTarget { if shouldShowTarget {
dropTargetId = sectionId dropTargetId = sectionId
} else if dropTargetId == sectionId { } else if dropTargetId == sectionId {
@@ -585,13 +590,13 @@ struct TripDetailView: View {
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding( .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding(
get: { dropTargetId == sectionId }, get: { dropTargetId == sectionId },
set: { targeted in set: { targeted in
// Only accept custom items on travel, not other travel // Only accept custom items on travel, not other travel
let shouldShow = targeted && draggedItem != nil let shouldShow = targeted && draggedItem != nil
withAnimation(.easeInOut(duration: 0.2)) { Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
if shouldShow { if shouldShow {
dropTargetId = sectionId dropTargetId = sectionId
} else if dropTargetId == sectionId { } else if dropTargetId == sectionId {
dropTargetId = nil dropTargetId = nil
} }
} }
} }
@@ -628,7 +633,7 @@ struct TripDetailView: View {
set: { targeted in set: { targeted in
// Only accept custom items, not travel // Only accept custom items, not travel
let shouldShow = targeted && draggedItem != nil && draggedItem?.id != item.id let shouldShow = targeted && draggedItem != nil && draggedItem?.id != item.id
withAnimation(.easeInOut(duration: 0.2)) { Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
if shouldShow { if shouldShow {
dropTargetId = sectionId dropTargetId = sectionId
} else if dropTargetId == sectionId { } else if dropTargetId == sectionId {
@@ -654,7 +659,7 @@ struct TripDetailView: View {
set: { targeted in set: { targeted in
// Only accept custom items, not travel // Only accept custom items, not travel
let shouldShow = targeted && draggedItem != nil let shouldShow = targeted && draggedItem != nil
withAnimation(.easeInOut(duration: 0.2)) { Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
if shouldShow { if shouldShow {
dropTargetId = sectionId dropTargetId = sectionId
} else if dropTargetId == sectionId { } else if dropTargetId == sectionId {
@@ -1323,7 +1328,7 @@ struct TripDetailView: View {
do { do {
try modelContext.save() try modelContext.save()
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { Theme.Animation.withMotion(.spring(response: 0.3, dampingFraction: 0.6)) {
isSaved = true isSaved = true
} }
AnalyticsManager.shared.track(.tripSaved( AnalyticsManager.shared.track(.tripSaved(
@@ -1348,7 +1353,7 @@ struct TripDetailView: View {
modelContext.delete(savedTrip) modelContext.delete(savedTrip)
} }
try modelContext.save() try modelContext.save()
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { Theme.Animation.withMotion(.spring(response: 0.3, dampingFraction: 0.6)) {
isSaved = false isSaved = false
} }
AnalyticsManager.shared.track(.tripDeleted(tripId: tripId.uuidString)) AnalyticsManager.shared.track(.tripDeleted(tripId: tripId.uuidString))
@@ -1818,7 +1823,7 @@ struct TravelSection: View {
.background(Theme.routeGold.opacity(0.2)) .background(Theme.routeGold.opacity(0.2))
Button { Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { Theme.Animation.withMotion(.spring(response: 0.3, dampingFraction: 0.8)) {
showEVChargers.toggle() showEVChargers.toggle()
} }
} label: { } label: {
@@ -1836,6 +1841,7 @@ struct TravelSection: View {
Image(systemName: showEVChargers ? "chevron.up" : "chevron.down") Image(systemName: showEVChargers ? "chevron.up" : "chevron.down")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(.horizontal, Theme.Spacing.md) .padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm) .padding(.vertical, Theme.Spacing.sm)

View File

@@ -308,7 +308,7 @@ struct TripOptionsView: View {
hasAppliedDemoSelection = true hasAppliedDemoSelection = true
// Auto-select "Most Games" sort after a delay // Auto-select "Most Games" sort after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) { DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
withAnimation(.easeInOut(duration: 0.3)) { Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
sortOption = DemoConfig.demoSortOption sortOption = DemoConfig.demoSortOption
} }
} }
@@ -329,7 +329,7 @@ struct TripOptionsView: View {
Menu { Menu {
ForEach(TripSortOption.allCases) { option in ForEach(TripSortOption.allCases) { option in
Button { Button {
withAnimation(.easeInOut(duration: 0.2)) { Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
sortOption = option sortOption = option
} }
} label: { } label: {
@@ -345,6 +345,7 @@ struct TripOptionsView: View {
.font(.subheadline) .font(.subheadline)
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
.font(.caption) .font(.caption)
.accessibilityHidden(true)
} }
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -397,6 +398,7 @@ struct TripOptionsView: View {
.contentTransition(.identity) .contentTransition(.identity)
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
} }
.foregroundStyle(paceFilter == .all ? Theme.textPrimary(colorScheme) : Theme.warmOrange) .foregroundStyle(paceFilter == .all ? Theme.textPrimary(colorScheme) : Theme.warmOrange)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -420,12 +422,12 @@ struct TripOptionsView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(CitiesFilter.allCases) { filter in ForEach(CitiesFilter.allCases) { filter in
Button { Button {
withAnimation(.easeInOut(duration: 0.2)) { Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
citiesFilter = filter citiesFilter = filter
} }
} label: { } label: {
Text(filter.displayName) Text(filter.displayName)
.font(.system(size: 13, weight: citiesFilter == filter ? .semibold : .medium)) .font(.caption.weight(citiesFilter == filter ? .semibold : .medium))
.foregroundStyle(citiesFilter == filter ? .white : Theme.textPrimary(colorScheme)) .foregroundStyle(citiesFilter == filter ? .white : Theme.textPrimary(colorScheme))
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
@@ -446,15 +448,16 @@ struct TripOptionsView: View {
private var emptyFilterState: some View { private var emptyFilterState: some View {
VStack(spacing: Theme.Spacing.md) { VStack(spacing: Theme.Spacing.md) {
Image(systemName: "line.3.horizontal.decrease.circle") Image(systemName: "line.3.horizontal.decrease.circle")
.font(.system(size: 48)) .font(.largeTitle)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
Text("No routes match your filters") Text("No routes match your filters")
.font(.body) .font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
Button { Button {
withAnimation { Theme.Animation.withMotion {
citiesFilter = .noLimit citiesFilter = .noLimit
paceFilter = .all paceFilter = .all
} }
@@ -524,6 +527,7 @@ struct TripOptionCard: View {
.font(.caption2) .font(.caption2)
} }
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text(uniqueCities.last ?? "") Text(uniqueCities.last ?? "")
.font(.subheadline) .font(.subheadline)
@@ -560,7 +564,7 @@ struct TripOptionCard: View {
// AI-generated description (after stats) // AI-generated description (after stats)
if let description = aiDescription { if let description = aiDescription {
Text(description) Text(description)
.font(.system(size: 13, weight: .regular)) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.transition(.opacity) .transition(.opacity)
@@ -578,8 +582,9 @@ struct TripOptionCard: View {
// Right: Chevron // Right: Chevron
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme)) .background(Theme.cardBackground(colorScheme))
@@ -607,7 +612,7 @@ struct TripOptionCard: View {
let input = RouteDescriptionInput(from: option, games: games) let input = RouteDescriptionInput(from: option, games: games)
if let description = await RouteDescriptionGenerator.shared.generateDescription(for: input) { if let description = await RouteDescriptionGenerator.shared.generateDescription(for: input) {
withAnimation(.easeInOut(duration: 0.3)) { Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
aiDescription = description aiDescription = description
} }
} }

View File

@@ -25,6 +25,7 @@ struct DateRangePicker: View {
private let calendar = Calendar.current private let calendar = Calendar.current
private let daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"] private let daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"]
private let daysOfWeekFull = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
private var monthYearString: String { private var monthYearString: String {
let formatter = DateFormatter() let formatter = DateFormatter()
@@ -96,13 +97,13 @@ struct DateRangePicker: View {
if isDemoMode && !hasAppliedDemoSelection { if isDemoMode && !hasAppliedDemoSelection {
hasAppliedDemoSelection = true hasAppliedDemoSelection = true
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) { DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
withAnimation(.easeInOut(duration: 0.3)) { Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
// Navigate to demo month // Navigate to demo month
displayedMonth = DemoConfig.demoStartDate displayedMonth = DemoConfig.demoStartDate
} }
} }
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.5) {
withAnimation(.easeInOut(duration: 0.3)) { Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
startDate = DemoConfig.demoStartDate startDate = DemoConfig.demoStartDate
endDate = DemoConfig.demoEndDate endDate = DemoConfig.demoEndDate
selectionState = .complete selectionState = .complete
@@ -119,7 +120,7 @@ struct DateRangePicker: View {
let newYear = calendar.component(.year, from: newValue) let newYear = calendar.component(.year, from: newValue)
if oldMonth != newMonth || oldYear != newYear { if oldMonth != newMonth || oldYear != newYear {
withAnimation(.easeInOut(duration: 0.2)) { Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
displayedMonth = calendar.startOfDay(for: newValue) displayedMonth = calendar.startOfDay(for: newValue)
} }
} }
@@ -148,6 +149,7 @@ struct DateRangePicker: View {
Image(systemName: "arrow.right") Image(systemName: "arrow.right")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
// End date // End date
VStack(alignment: .trailing, spacing: 4) { VStack(alignment: .trailing, spacing: 4) {
@@ -168,17 +170,18 @@ struct DateRangePicker: View {
private var monthNavigation: some View { private var monthNavigation: some View {
HStack { HStack {
Button { Button {
withAnimation(.easeInOut(duration: 0.2)) { Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth
} }
} label: { } label: {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.body) .font(.body)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.frame(width: 36, height: 36) .frame(minWidth: 44, minHeight: 44)
.background(Theme.warmOrange.opacity(0.15)) .background(Theme.warmOrange.opacity(0.15))
.clipShape(Circle()) .clipShape(Circle())
} }
.accessibilityLabel("Previous month")
.accessibilityIdentifier("wizard.dates.previousMonth") .accessibilityIdentifier("wizard.dates.previousMonth")
Spacer() Spacer()
@@ -191,28 +194,30 @@ struct DateRangePicker: View {
Spacer() Spacer()
Button { Button {
withAnimation(.easeInOut(duration: 0.2)) { Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth
} }
} label: { } label: {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.body) .font(.body)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.frame(width: 36, height: 36) .frame(minWidth: 44, minHeight: 44)
.background(Theme.warmOrange.opacity(0.15)) .background(Theme.warmOrange.opacity(0.15))
.clipShape(Circle()) .clipShape(Circle())
} }
.accessibilityLabel("Next month")
.accessibilityIdentifier("wizard.dates.nextMonth") .accessibilityIdentifier("wizard.dates.nextMonth")
} }
} }
private var daysOfWeekHeader: some View { private var daysOfWeekHeader: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { _, day in ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { index, day in
Text(day) Text(day)
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.accessibilityLabel(daysOfWeekFull[index])
} }
} }
} }
@@ -243,6 +248,7 @@ struct DateRangePicker: View {
HStack(spacing: Theme.Spacing.xs) { HStack(spacing: Theme.Spacing.xs) {
Image(systemName: "calendar.badge.clock") Image(systemName: "calendar.badge.clock")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("\(tripDuration) day\(tripDuration == 1 ? "" : "s")") Text("\(tripDuration) day\(tripDuration == 1 ? "" : "s")")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
@@ -348,7 +354,7 @@ struct DayCell: View {
} }
Text(dayNumber) Text(dayNumber)
.font(.system(size: 14, weight: (isStart || isEnd) ? .bold : .medium)) .font(.subheadline)
.foregroundStyle( .foregroundStyle(
isPast ? Theme.textMuted(colorScheme).opacity(0.5) : isPast ? Theme.textMuted(colorScheme).opacity(0.5) :
(isStart || isEnd) ? .white : (isStart || isEnd) ? .white :

View File

@@ -123,26 +123,53 @@ struct GamePickerStep: View {
.fontWeight(.medium) .fontWeight(.medium)
.foregroundStyle(Theme.textSecondary(colorScheme)) .foregroundStyle(Theme.textSecondary(colorScheme))
Button { if let value = value {
if isEnabled { onTap() } HStack(spacing: Theme.Spacing.sm) {
} label: { Button {
HStack { if isEnabled { onTap() }
Image(systemName: icon) } label: {
.foregroundStyle(isEnabled ? Theme.warmOrange : Theme.textMuted(colorScheme)) HStack {
Image(systemName: icon)
.foregroundStyle(isEnabled ? Theme.warmOrange : Theme.textMuted(colorScheme))
.accessibilityHidden(true)
if let value = value { Text(value)
Text(value) .font(.subheadline)
.font(.subheadline) .foregroundStyle(Theme.textPrimary(colorScheme))
.foregroundStyle(Theme.textPrimary(colorScheme)) .lineLimit(1)
.lineLimit(1)
Spacer() Spacer()
Button(action: onClear) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme))
} }
} else { .contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!isEnabled)
Button(action: onClear) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme))
}
.buttonStyle(.plain)
.minimumHitTarget()
.accessibilityLabel("Clear \(label.lowercased()) selection")
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.warmOrange, lineWidth: 2)
)
.opacity(isEnabled ? 1 : 0.5)
} else {
Button {
if isEnabled { onTap() }
} label: {
HStack {
Image(systemName: icon)
.foregroundStyle(isEnabled ? Theme.warmOrange : Theme.textMuted(colorScheme))
.accessibilityHidden(true)
Text(placeholder) Text(placeholder)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
@@ -152,19 +179,20 @@ struct GamePickerStep: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.textMuted(colorScheme).opacity(0.3), lineWidth: 1)
)
.opacity(isEnabled ? 1 : 0.5)
} }
.padding(Theme.Spacing.md) .buttonStyle(.plain)
.background(Theme.cardBackgroundElevated(colorScheme)) .disabled(!isEnabled)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(value != nil ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: value != nil ? 2 : 1)
)
.opacity(isEnabled ? 1 : 0.5)
} }
.buttonStyle(.plain)
.disabled(!isEnabled)
} }
} }
@@ -177,6 +205,7 @@ struct GamePickerStep: View {
HStack { HStack {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("\(selectedGameIds.count) game\(selectedGameIds.count == 1 ? "" : "s") selected") Text("\(selectedGameIds.count) game\(selectedGameIds.count == 1 ? "" : "s") selected")
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
@@ -201,6 +230,8 @@ struct GamePickerStep: View {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
} }
.minimumHitTarget()
.accessibilityLabel("Remove \(game.matchupDescription)")
} }
.padding(Theme.Spacing.sm) .padding(Theme.Spacing.sm)
.background(Theme.cardBackgroundElevated(colorScheme)) .background(Theme.cardBackgroundElevated(colorScheme))
@@ -236,6 +267,7 @@ struct GamePickerStep: View {
HStack { HStack {
Image(systemName: "calendar") Image(systemName: "calendar")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Trip Date Range") Text("Trip Date Range")
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
@@ -353,15 +385,18 @@ private struct SportsPickerSheet: View {
if selectedSports.contains(sport) { if selectedSports.contains(sport) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
} }
.padding(.vertical, Theme.Spacing.xs) .padding(.vertical, Theme.Spacing.xs)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityAddTraits(selectedSports.contains(sport) ? .isSelected : [])
} }
} }
.listStyle(.plain) .listStyle(.plain)
@@ -451,15 +486,18 @@ private struct TeamsPickerSheet: View {
if selectedTeamIds.contains(team.id) { if selectedTeamIds.contains(team.id) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
} }
.padding(.vertical, Theme.Spacing.xs) .padding(.vertical, Theme.Spacing.xs)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityAddTraits(selectedTeamIds.contains(team.id) ? .isSelected : [])
} }
} header: { } header: {
HStack(spacing: Theme.Spacing.xs) { HStack(spacing: Theme.Spacing.xs) {
@@ -555,15 +593,19 @@ private struct GamesPickerSheet: View {
if selectedGameIds.contains(game.id) { if selectedGameIds.contains(game.id) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
} }
.padding(.vertical, Theme.Spacing.xs) .padding(.vertical, Theme.Spacing.xs)
.contentShape(Rectangle()) .contentShape(Rectangle())
.accessibilityElement(children: .combine)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityAddTraits(selectedGameIds.contains(game.id) ? .isSelected : [])
} }
} header: { } header: {
Text(date, style: .date) Text(date, style: .date)

View File

@@ -48,6 +48,7 @@ struct LocationSearchSheet: View {
HStack { HStack {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.accessibilityHidden(true)
TextField("Search cities, addresses, places...", text: $searchText) TextField("Search cities, addresses, places...", text: $searchText)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.autocorrectionDisabled() .autocorrectionDisabled()
@@ -61,6 +62,8 @@ struct LocationSearchSheet: View {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.minimumHitTarget()
.accessibilityLabel("Clear search")
} }
} }
.padding() .padding()
@@ -85,6 +88,7 @@ struct LocationSearchSheet: View {
Image(systemName: "mappin.circle.fill") Image(systemName: "mappin.circle.fill")
.foregroundStyle(.red) .foregroundStyle(.red)
.font(.title2) .font(.title2)
.accessibilityHidden(true)
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(result.name) Text(result.name)
.foregroundStyle(.primary) .foregroundStyle(.primary)
@@ -97,6 +101,7 @@ struct LocationSearchSheet: View {
Spacer() Spacer()
Image(systemName: "plus.circle") Image(systemName: "plus.circle")
.foregroundStyle(.blue) .foregroundStyle(.blue)
.accessibilityHidden(true)
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)

View File

@@ -49,6 +49,7 @@ struct LocationsStep: View {
HStack(spacing: Theme.Spacing.sm) { HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "arrow.triangle.2.circlepath") Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Round trip (return to start)") Text("Round trip (return to start)")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
@@ -107,6 +108,7 @@ struct LocationsStep: View {
HStack { HStack {
Image(systemName: "mappin.circle.fill") Image(systemName: "mappin.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(location.name) Text(location.name)
@@ -128,6 +130,8 @@ struct LocationsStep: View {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
} }
.minimumHitTarget()
.accessibilityLabel("Clear location")
} }
.padding(Theme.Spacing.sm) .padding(Theme.Spacing.sm)
.background(Theme.cardBackgroundElevated(colorScheme)) .background(Theme.cardBackgroundElevated(colorScheme))
@@ -138,6 +142,7 @@ struct LocationsStep: View {
HStack { HStack {
Image(systemName: "plus.circle") Image(systemName: "plus.circle")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityLabel("Add location")
Text(placeholder) Text(placeholder)
.font(.subheadline) .font(.subheadline)
@@ -148,6 +153,7 @@ struct LocationsStep: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.sm) .padding(Theme.Spacing.sm)
.background(Theme.cardBackgroundElevated(colorScheme)) .background(Theme.cardBackgroundElevated(colorScheme))

View File

@@ -25,6 +25,7 @@ struct MustStopsStep: View {
HStack { HStack {
Image(systemName: "mappin.circle.fill") Image(systemName: "mappin.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text(location.name) Text(location.name)
.font(.subheadline) .font(.subheadline)
@@ -38,6 +39,8 @@ struct MustStopsStep: View {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
} }
.minimumHitTarget()
.accessibilityLabel("Remove location")
} }
.padding(Theme.Spacing.sm) .padding(Theme.Spacing.sm)
.background(Theme.cardBackgroundElevated(colorScheme)) .background(Theme.cardBackgroundElevated(colorScheme))
@@ -56,6 +59,7 @@ struct MustStopsStep: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
} }
.accessibilityLabel("Add must-see location")
Text("Skip this step if you don't have specific cities in mind") Text("Skip this step if you don't have specific cities in mind")
.font(.caption) .font(.caption)

View File

@@ -39,7 +39,7 @@ struct PlanningModeStep: View {
.onAppear { .onAppear {
if isDemoMode && selection == nil { if isDemoMode && selection == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) { DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
withAnimation(.easeInOut(duration: 0.3)) { Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
selection = DemoConfig.demoPlanningMode selection = DemoConfig.demoPlanningMode
} }
} }
@@ -63,6 +63,7 @@ private struct WizardModeCard: View {
.font(.title2) .font(.title2)
.foregroundStyle(isSelected ? Theme.warmOrange : Theme.textSecondary(colorScheme)) .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textSecondary(colorScheme))
.frame(width: 32) .frame(width: 32)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(mode.displayName) Text(mode.displayName)
@@ -79,6 +80,7 @@ private struct WizardModeCard: View {
if isSelected { if isSelected {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
@@ -89,7 +91,11 @@ private struct WizardModeCard: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isSelected ? 2 : 1) .stroke(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isSelected ? 2 : 1)
) )
.accessibilityElement(children: .combine)
} }
.accessibilityLabel("\(mode.displayName): \(mode.description)")
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
.accessibilityIdentifier("wizard.planningMode.\(mode.rawValue)") .accessibilityIdentifier("wizard.planningMode.\(mode.rawValue)")
.buttonStyle(.plain) .buttonStyle(.plain)
} }

View File

@@ -72,6 +72,7 @@ private struct OptionButton: View {
Image(systemName: icon) Image(systemName: icon)
.font(.title2) .font(.title2)
.foregroundStyle(isSelected ? Theme.warmOrange : Theme.textSecondary(colorScheme)) .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textSecondary(colorScheme))
.accessibilityHidden(true)
Text(title) Text(title)
.font(.caption) .font(.caption)
@@ -90,6 +91,8 @@ private struct OptionButton: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
} }
} }

View File

@@ -63,6 +63,7 @@ struct ReviewStep: View {
HStack(spacing: Theme.Spacing.xs) { HStack(spacing: Theme.Spacing.xs) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange) .foregroundStyle(.orange)
.accessibilityHidden(true)
Text("Complete all required fields to continue") Text("Complete all required fields to continue")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
@@ -85,6 +86,7 @@ struct ReviewStep: View {
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
} }
.accessibilityIdentifier("wizard.planTripButton") .accessibilityIdentifier("wizard.planTripButton")
.accessibilityHint("Creates trip itinerary based on your selections")
.disabled(!canPlanTrip || isPlanning) .disabled(!canPlanTrip || isPlanning)
} }
.padding(Theme.Spacing.lg) .padding(Theme.Spacing.lg)
@@ -155,6 +157,7 @@ private struct ReviewRow: View {
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.font(.caption) .font(.caption)
.foregroundStyle(.red) .foregroundStyle(.red)
.accessibilityHidden(true)
} }
} }
} }

View File

@@ -63,6 +63,7 @@ private struct RoutePreferenceCard: View {
.font(.title2) .font(.title2)
.foregroundStyle(isSelected ? Theme.warmOrange : Theme.textSecondary(colorScheme)) .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textSecondary(colorScheme))
.frame(width: 32) .frame(width: 32)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(preference.displayName) Text(preference.displayName)
@@ -79,6 +80,7 @@ private struct RoutePreferenceCard: View {
if isSelected { if isSelected {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
@@ -89,7 +91,11 @@ private struct RoutePreferenceCard: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isSelected ? 2 : 1) .stroke(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isSelected ? 2 : 1)
) )
.accessibilityElement(children: .combine)
} }
.accessibilityLabel(preference.displayName)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }

View File

@@ -60,7 +60,7 @@ struct SportsStep: View {
if isDemoMode && !hasAppliedDemoSelection && selectedSports.isEmpty { if isDemoMode && !hasAppliedDemoSelection && selectedSports.isEmpty {
hasAppliedDemoSelection = true hasAppliedDemoSelection = true
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) { DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
withAnimation(.easeInOut(duration: 0.3)) { Theme.Animation.withMotion(.easeInOut(duration: 0.3)) {
_ = selectedSports.insert(DemoConfig.demoSport) _ = selectedSports.insert(DemoConfig.demoSport)
} }
} }
@@ -92,6 +92,7 @@ private struct SportCard: View {
Image(systemName: sport.iconName) Image(systemName: sport.iconName)
.font(.title2) .font(.title2)
.foregroundStyle(cardColor) .foregroundStyle(cardColor)
.accessibilityHidden(true)
Text(sport.rawValue) Text(sport.rawValue)
.font(.caption) .font(.caption)
@@ -111,7 +112,15 @@ private struct SportCard: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(borderColor, lineWidth: isSelected ? 2 : 1) .stroke(borderColor, lineWidth: isSelected ? 2 : 1)
) )
.accessibilityElement(children: .combine)
} }
.accessibilityLabel(sport.rawValue)
.accessibilityValue(
isAvailable
? (isSelected ? "Selected" : "Not selected")
: "Unavailable"
)
.accessibilityAddTraits(isSelected ? .isSelected : [])
.accessibilityIdentifier("wizard.sports.\(sport.rawValue.lowercased())") .accessibilityIdentifier("wizard.sports.\(sport.rawValue.lowercased())")
.buttonStyle(.plain) .buttonStyle(.plain)
.opacity(isAvailable ? 1.0 : 0.5) .opacity(isAvailable ? 1.0 : 0.5)

View File

@@ -28,41 +28,60 @@ struct TeamPickerStep: View {
subtitle: "See their home and away games" subtitle: "See their home and away games"
) )
// Selection button if let team = selectedTeam {
Button { HStack(spacing: Theme.Spacing.sm) {
showTeamPicker = true Button {
} label: { showTeamPicker = true
HStack { } label: {
if let team = selectedTeam { HStack {
// Show selected team Circle()
Circle() .fill(team.primaryColor.map { Color(hex: $0) } ?? team.sport.themeColor)
.fill(team.primaryColor.map { Color(hex: $0) } ?? team.sport.themeColor) .frame(width: 24, height: 24)
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(team.fullName) Text(team.fullName)
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundStyle(Theme.textPrimary(colorScheme)) .foregroundStyle(Theme.textPrimary(colorScheme))
Text(team.sport.rawValue) Text(team.sport.rawValue)
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
}
Spacer()
} }
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Spacer() Button {
selectedTeamId = nil
Button { selectedSport = nil
selectedTeamId = nil } label: {
selectedSport = nil Image(systemName: "xmark.circle.fill")
} label: { .foregroundStyle(Theme.textMuted(colorScheme))
Image(systemName: "xmark.circle.fill") }
.foregroundStyle(Theme.textMuted(colorScheme)) .buttonStyle(.plain)
} .minimumHitTarget()
} else { .accessibilityLabel("Clear team selection")
// Empty state }
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.warmOrange, lineWidth: 2)
)
} else {
// Selection button
Button {
showTeamPicker = true
} label: {
HStack {
Image(systemName: "person.2.fill") Image(systemName: "person.2.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Select a team") Text("Select a team")
.font(.subheadline) .font(.subheadline)
@@ -73,17 +92,18 @@ struct TeamPickerStep: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.textMuted(colorScheme).opacity(0.3), lineWidth: 1)
)
} }
.padding(Theme.Spacing.md) .buttonStyle(.plain)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(selectedTeam != nil ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: selectedTeam != nil ? 2 : 1)
)
} }
.buttonStyle(.plain)
} }
.padding(Theme.Spacing.lg) .padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme)) .background(Theme.cardBackground(colorScheme))
@@ -214,11 +234,14 @@ private struct TeamListView: View {
if selectedTeamId == team.id { if selectedTeamId == team.id {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} }
} }
.padding(.vertical, Theme.Spacing.xs) .padding(.vertical, Theme.Spacing.xs)
.accessibilityElement(children: .combine)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityAddTraits(selectedTeamId == team.id ? .isSelected : [])
} }
} }
.listStyle(.plain) .listStyle(.plain)

View File

@@ -33,28 +33,46 @@ struct TeamFirstWizardStep: View {
subtitle: "Select 2 or more teams to find optimal trip windows" subtitle: "Select 2 or more teams to find optimal trip windows"
) )
// Selection button if !selectedTeams.isEmpty {
Button { HStack(spacing: Theme.Spacing.sm) {
showTeamPicker = true Button {
} label: { showTeamPicker = true
HStack { } label: {
if !selectedTeams.isEmpty { HStack {
// Show selected teams teamPreview
teamPreview Spacer()
Spacer()
Button {
selectedTeamIds.removeAll()
selectedSport = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme))
} }
} else { .contentShape(Rectangle())
// Empty state }
.buttonStyle(.plain)
Button {
selectedTeamIds.removeAll()
selectedSport = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme))
}
.buttonStyle(.plain)
.minimumHitTarget()
.accessibilityLabel("Clear all teams")
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.warmOrange, lineWidth: 2)
)
} else {
// Selection button
Button {
showTeamPicker = true
} label: {
HStack {
Image(systemName: "person.2.fill") Image(systemName: "person.2.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
Text("Select teams") Text("Select teams")
.font(.subheadline) .font(.subheadline)
@@ -65,17 +83,18 @@ struct TeamFirstWizardStep: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
} }
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.textMuted(colorScheme).opacity(0.3), lineWidth: 1)
)
} }
.padding(Theme.Spacing.md) .buttonStyle(.plain)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(isValid ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isValid ? 2 : 1)
)
} }
.buttonStyle(.plain)
// Validation message // Validation message
if selectedTeamIds.isEmpty { if selectedTeamIds.isEmpty {
@@ -139,6 +158,7 @@ struct TeamFirstWizardStep: View {
.zIndex(Double(4 - index)) .zIndex(Double(4 - index))
} }
} }
.accessibilityHidden(true)
Text("\(selectedTeamIds.count) teams") Text("\(selectedTeamIds.count) teams")
.font(.subheadline) .font(.subheadline)
@@ -279,14 +299,17 @@ private struct TeamMultiSelectListView: View {
if selectedTeamIds.contains(team.id) { if selectedTeamIds.contains(team.id) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange) .foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
} else { } else {
Image(systemName: "circle") Image(systemName: "circle")
.foregroundStyle(Theme.textMuted(colorScheme).opacity(0.5)) .foregroundStyle(Theme.textMuted(colorScheme).opacity(0.5))
.accessibilityHidden(true)
} }
} }
.padding(.vertical, Theme.Spacing.xs) .padding(.vertical, Theme.Spacing.xs)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityAddTraits(selectedTeamIds.contains(team.id) ? .isSelected : [])
} }
} }
.listStyle(.plain) .listStyle(.plain)
@@ -316,7 +339,7 @@ private struct TeamMultiSelectListView: View {
} }
private func toggleTeam(_ team: Team) { private func toggleTeam(_ team: Team) {
withAnimation(.easeInOut(duration: 0.15)) { Theme.Animation.withMotion(.easeInOut(duration: 0.15)) {
if selectedTeamIds.contains(team.id) { if selectedTeamIds.contains(team.id) {
selectedTeamIds.remove(team.id) selectedTeamIds.remove(team.id)
} else { } else {

View File

@@ -133,7 +133,7 @@ struct TripWizardView: View {
} }
.padding(Theme.Spacing.md) .padding(Theme.Spacing.md)
.frame(width: geometry.size.width) .frame(width: geometry.size.width)
.animation(.easeInOut(duration: 0.2), value: viewModel.areStepsVisible) .animation(Theme.Animation.prefersReducedMotion ? .none : .easeInOut(duration: 0.2), value: viewModel.areStepsVisible)
} }
} }
.themedBackground() .themedBackground()

View File

@@ -238,16 +238,15 @@ struct BootstrappedContentView: View {
private func performBackgroundSync(context: ModelContext) async { private func performBackgroundSync(context: ModelContext) async {
let log = SyncLogger.shared let log = SyncLogger.shared
log.log("🔄 [SYNC] Starting background sync...") log.log("🔄 [SYNC] Starting background sync...")
AccessibilityAnnouncer.announce("Sync started.")
// Log diagnostic info for debugging CloudKit container issues // Log diagnostic info for debugging CloudKit container issues
let bundleId = Bundle.main.bundleIdentifier ?? "unknown" let bundleId = Bundle.main.bundleIdentifier ?? "unknown"
let container = CKContainer.default() let container = CloudKitContainerConfig.makeContainer()
let containerId = container.containerIdentifier ?? "unknown" let containerId = container.containerIdentifier ?? "unknown"
log.log("🔧 [DIAG] Bundle ID: \(bundleId)") log.log("🔧 [DIAG] Bundle ID: \(bundleId)")
log.log("🔧 [DIAG] CKContainer.default(): \(containerId)") log.log("🔧 [DIAG] CloudKit container: \(containerId)")
if let entitlementContainers = Bundle.main.object(forInfoDictionaryKey: "com.apple.developer.icloud-container-identifiers") as? [String] { log.log("🔧 [DIAG] Configured container: \(CloudKitContainerConfig.identifier)")
log.log("🔧 [DIAG] Entitlement containers: \(entitlementContainers.joined(separator: ", "))")
}
if let accountStatus = try? await container.accountStatus() { if let accountStatus = try? await container.accountStatus() {
log.log("🔧 [DIAG] iCloud account status: \(accountStatus.rawValue) (0=couldNotDetermine, 1=available, 2=restricted, 3=noAccount)") log.log("🔧 [DIAG] iCloud account status: \(accountStatus.rawValue) (0=couldNotDetermine, 1=available, 2=restricted, 3=noAccount)")
} else { } else {
@@ -294,10 +293,13 @@ struct BootstrappedContentView: View {
} else { } else {
log.log("🔄 [SYNC] No updates - skipping DataProvider reload") log.log("🔄 [SYNC] No updates - skipping DataProvider reload")
} }
AccessibilityAnnouncer.announce("Sync complete. Updated \(result.totalUpdated) records.")
} catch CanonicalSyncService.SyncError.cloudKitUnavailable { } catch CanonicalSyncService.SyncError.cloudKitUnavailable {
log.log("❌ [SYNC] CloudKit unavailable - using local data only") log.log("❌ [SYNC] CloudKit unavailable - using local data only")
AccessibilityAnnouncer.announce("Cloud sync unavailable. Using local data.")
} catch { } catch {
log.log("❌ [SYNC] Error: \(error.localizedDescription)") log.log("❌ [SYNC] Error: \(error.localizedDescription)")
AccessibilityAnnouncer.announce("Sync failed. \(error.localizedDescription)")
} }
} }

View File

@@ -6,7 +6,7 @@
<string>development</string> <string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key> <key>com.apple.developer.icloud-container-identifiers</key>
<array> <array>
<string>iCloud.com.88oakapps.SportsTime.Debug</string> <string>iCloud.com.88oakapps.SportsTime</string>
</array> </array>
<key>com.apple.developer.icloud-services</key> <key>com.apple.developer.icloud-services</key>
<array> <array>

View File

@@ -0,0 +1,71 @@
import Testing
import Foundation
@testable import SportsTime
@Suite("PollVotingViewModel")
@MainActor
struct PollVotingViewModelTests {
@Test("initializeRankings defaults to original trip order")
func initializeRankings_defaultOrder() {
let viewModel = PollVotingViewModel()
viewModel.initializeRankings(tripCount: 5, existingVote: nil)
#expect(viewModel.rankings == [0, 1, 2, 3, 4])
}
@Test("initializeRankings uses existing vote order")
func initializeRankings_existingVoteOrder() {
let viewModel = PollVotingViewModel()
let existingVote = PollVote(
pollId: UUID(),
odg: "user_123",
rankings: [2, 0, 3, 1]
)
viewModel.initializeRankings(tripCount: 4, existingVote: existingVote)
#expect(viewModel.rankings == [2, 0, 3, 1])
}
@Test("moveTripUp swaps with previous item")
func moveTripUp_swapsWithPrevious() {
let viewModel = PollVotingViewModel()
viewModel.rankings = [0, 1, 2, 3]
viewModel.moveTripUp(at: 2)
#expect(viewModel.rankings == [0, 2, 1, 3])
}
@Test("moveTripDown swaps with next item")
func moveTripDown_swapsWithNext() {
let viewModel = PollVotingViewModel()
viewModel.rankings = [0, 1, 2, 3]
viewModel.moveTripDown(at: 1)
#expect(viewModel.rankings == [0, 2, 1, 3])
}
@Test("moveTripUp is no-op at first index")
func moveTripUp_firstIndexNoOp() {
let viewModel = PollVotingViewModel()
viewModel.rankings = [0, 1, 2]
viewModel.moveTripUp(at: 0)
#expect(viewModel.rankings == [0, 1, 2])
}
@Test("moveTripDown is no-op at last index")
func moveTripDown_lastIndexNoOp() {
let viewModel = PollVotingViewModel()
viewModel.rankings = [0, 1, 2]
viewModel.moveTripDown(at: 2)
#expect(viewModel.rankings == [0, 1, 2])
}
}

View File

@@ -18,6 +18,28 @@ final class SportsTimeUITests: XCTestCase {
// Put teardown code here. // Put teardown code here.
} }
// MARK: - Accessibility Smoke Tests
/// Verifies primary entry flow remains usable at a large accessibility text size.
@MainActor
func testAccessibilitySmoke_LargeDynamicTypeEntryFlow() throws {
let app = XCUIApplication()
app.launchArguments = [
"-UIPreferredContentSizeCategoryName",
"UICTContentSizeCategoryAccessibilityXXXL"
]
app.launch()
let startPlanningButton = app.buttons["home.startPlanningButton"]
XCTAssertTrue(startPlanningButton.waitForExistence(timeout: 20), "Start Planning should exist at large Dynamic Type")
XCTAssertTrue(startPlanningButton.isHittable, "Start Planning should remain hittable at large Dynamic Type")
startPlanningButton.tap()
let dateRangeMode = app.buttons["wizard.planningMode.dateRange"]
XCTAssertTrue(dateRangeMode.waitForExistence(timeout: 10), "Planning mode options should load")
XCTAssertTrue(dateRangeMode.isHittable, "Planning mode option should remain hittable at large Dynamic Type")
}
// MARK: - Demo Flow Test (Continuous Scroll Mode) // MARK: - Demo Flow Test (Continuous Scroll Mode)
/// Complete trip planning demo with continuous smooth scrolling. /// Complete trip planning demo with continuous smooth scrolling.