From d63d311cab4328ea197025a3054f36831983b7a3 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 11 Feb 2026 09:27:23 -0600 Subject: [PATCH] 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 --- .claude/settings.local.json | 6 +- SportsTime.xcodeproj/project.pbxproj | 2 + .../Core/Analytics/AnalyticsManager.swift | 3 - .../Services/CloudKitContainerConfig.swift | 14 +++ .../Core/Services/CloudKitService.swift | 3 +- .../Core/Services/ItineraryItemService.swift | 2 +- .../Services/LocationPermissionManager.swift | 3 +- SportsTime/Core/Services/PollService.swift | 3 +- .../Core/Services/VisitPhotoService.swift | 2 +- .../Core/Theme/AnimatedBackground.swift | 4 + .../Core/Theme/AnimatedComponents.swift | 7 ++ .../Theme/Loading/LoadingPlaceholder.swift | 10 ++ .../Core/Theme/Loading/LoadingSheet.swift | 3 +- .../Core/Theme/Loading/LoadingSpinner.swift | 4 + SportsTime/Core/Theme/SportSelectorGrid.swift | 26 +++-- SportsTime/Core/Theme/Theme.swift | 58 ++++++++--- SportsTime/Core/Theme/ViewModifiers.swift | 72 +++++++++++--- SportsTime/Export/Views/ShareButton.swift | 1 + .../Export/Views/SharePreviewView.swift | 7 ++ SportsTime/Features/Home/Views/HomeView.swift | 28 +++++- .../Home/Views/SuggestedTripCard.swift | 8 ++ .../Classic/HomeContent_Classic.swift | 12 +++ .../Classic/HomeContent_ClassicAnimated.swift | 12 +++ .../Paywall/ViewModifiers/ProGate.swift | 2 + .../Paywall/Views/OnboardingPaywallView.swift | 32 ++++-- .../Features/Paywall/Views/PaywallView.swift | 7 +- .../Features/Paywall/Views/ProBadge.swift | 1 + .../ViewModels/PollVotingViewModel.swift | 10 ++ .../Polls/Views/PollCreationView.swift | 4 + .../Features/Polls/Views/PollDetailView.swift | 35 ++++++- .../Features/Polls/Views/PollVotingView.swift | 33 ++++++- .../Features/Polls/Views/PollsListView.swift | 1 + .../ViewModels/MapInteractionViewModel.swift | 4 +- .../Progress/Views/AchievementsListView.swift | 28 ++++-- .../Views/Components/GamesHistoryRow.swift | 3 + .../Views/Components/VisitListCard.swift | 5 +- .../Views/GameMatchConfirmationView.swift | 17 ++++ .../Progress/Views/GamesHistoryView.swift | 6 +- .../Progress/Views/PhotoImportView.swift | 7 +- .../Progress/Views/ProgressMapView.swift | 10 +- .../Progress/Views/ProgressTabView.swift | 24 ++++- .../Views/StadiumVisitHistoryView.swift | 3 +- .../Progress/Views/StadiumVisitSheet.swift | 3 + .../Progress/Views/VisitDetailView.swift | 4 + .../Schedule/Views/ScheduleListView.swift | 6 ++ .../Settings/Views/SettingsView.swift | 52 ++++++++-- .../Trip/Views/AddItem/CategoryPicker.swift | 4 +- .../Trip/Views/AddItem/PlaceSearchSheet.swift | 4 + .../Views/AddItem/QuickAddItemSheet.swift | 18 ++-- .../Features/Trip/Views/AddItemSheet.swift | 8 ++ .../Views/ItineraryRows/CustomItemRow.swift | 3 + .../Views/ItineraryRows/DayHeaderRow.swift | 2 + .../Views/ItineraryRows/GameItemRow.swift | 3 + .../Views/ItineraryRows/TravelItemRow.swift | 5 + .../Views/ItineraryTableViewController.swift | 6 ++ .../Trip/Views/RegionMapSelector.swift | 25 +++++ .../Features/Trip/Views/TeamPickerView.swift | 10 +- .../Trip/Views/TimelineItemView.swift | 62 +++++++----- .../Features/Trip/Views/TripDetailView.swift | 34 ++++--- .../Features/Trip/Views/TripOptionsView.swift | 23 +++-- .../Views/Wizard/Steps/DateRangePicker.swift | 24 +++-- .../Views/Wizard/Steps/GamePickerStep.swift | 96 ++++++++++++------ .../Wizard/Steps/LocationSearchSheet.swift | 5 + .../Views/Wizard/Steps/LocationsStep.swift | 6 ++ .../Views/Wizard/Steps/MustStopsStep.swift | 4 + .../Views/Wizard/Steps/PlanningModeStep.swift | 8 +- .../Views/Wizard/Steps/RepeatCitiesStep.swift | 3 + .../Trip/Views/Wizard/Steps/ReviewStep.swift | 3 + .../Wizard/Steps/RoutePreferenceStep.swift | 6 ++ .../Trip/Views/Wizard/Steps/SportsStep.swift | 11 ++- .../Views/Wizard/Steps/TeamPickerStep.swift | 97 ++++++++++++------- .../Views/Wizard/TeamFirstWizardStep.swift | 79 +++++++++------ .../Trip/Views/Wizard/TripWizardView.swift | 2 +- SportsTime/SportsTimeApp.swift | 12 ++- SportsTime/SportsTimeDebug.entitlements | 2 +- .../Polls/PollVotingViewModelTests.swift | 71 ++++++++++++++ SportsTimeUITests/SportsTimeUITests.swift | 22 +++++ 77 files changed, 982 insertions(+), 263 deletions(-) create mode 100644 SportsTime/Core/Services/CloudKitContainerConfig.swift create mode 100644 SportsTimeTests/Features/Polls/PollVotingViewModelTests.swift diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2cf334b..bf15964 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -46,7 +46,11 @@ "WebFetch(domain:swiftpackageindex.com)", "WebFetch(domain:posthog.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)" ] } } diff --git a/SportsTime.xcodeproj/project.pbxproj b/SportsTime.xcodeproj/project.pbxproj index 62f77d9..102b239 100644 --- a/SportsTime.xcodeproj/project.pbxproj +++ b/SportsTime.xcodeproj/project.pbxproj @@ -346,6 +346,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTime.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = QND55P4443; @@ -365,6 +366,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.SportsTime; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; diff --git a/SportsTime/Core/Analytics/AnalyticsManager.swift b/SportsTime/Core/Analytics/AnalyticsManager.swift index a779363..9b7ca19 100644 --- a/SportsTime/Core/Analytics/AnalyticsManager.swift +++ b/SportsTime/Core/Analytics/AnalyticsManager.swift @@ -143,9 +143,6 @@ final class AnalyticsManager { var props: [String: Any] = ["screen_name": screenName] if let properties { props.merge(properties) { _, new in new } } - #if DEBUG - print("[Analytics] screen_viewed: \(screenName)") - #endif PostHogSDK.shared.capture("screen_viewed", properties: props) } diff --git a/SportsTime/Core/Services/CloudKitContainerConfig.swift b/SportsTime/Core/Services/CloudKitContainerConfig.swift new file mode 100644 index 0000000..3b088f5 --- /dev/null +++ b/SportsTime/Core/Services/CloudKitContainerConfig.swift @@ -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) + } +} diff --git a/SportsTime/Core/Services/CloudKitService.swift b/SportsTime/Core/Services/CloudKitService.swift index ecedd57..6e832fa 100644 --- a/SportsTime/Core/Services/CloudKitService.swift +++ b/SportsTime/Core/Services/CloudKitService.swift @@ -109,8 +109,7 @@ actor CloudKitService { private let deltaOverlapSeconds: TimeInterval = 120 private init() { - // Use target entitlements (debug/prod) instead of hardcoding a container ID. - self.container = CKContainer.default() + self.container = CloudKitContainerConfig.makeContainer() self.publicDatabase = container.publicCloudDatabase } diff --git a/SportsTime/Core/Services/ItineraryItemService.swift b/SportsTime/Core/Services/ItineraryItemService.swift index 8e4c6a4..e94cb5b 100644 --- a/SportsTime/Core/Services/ItineraryItemService.swift +++ b/SportsTime/Core/Services/ItineraryItemService.swift @@ -5,7 +5,7 @@ import CloudKit actor ItineraryItemService { static let shared = ItineraryItemService() - private let container = CKContainer.default() + private let container = CloudKitContainerConfig.makeContainer() private var database: CKDatabase { container.privateCloudDatabase } private let recordType = "ItineraryItem" diff --git a/SportsTime/Core/Services/LocationPermissionManager.swift b/SportsTime/Core/Services/LocationPermissionManager.swift index 48351ff..11fea21 100644 --- a/SportsTime/Core/Services/LocationPermissionManager.swift +++ b/SportsTime/Core/Services/LocationPermissionManager.swift @@ -108,8 +108,9 @@ struct LocationPermissionView: View { var body: some View { VStack(spacing: 16) { Image(systemName: "location.circle.fill") - .font(.system(size: 60)) + .font(.largeTitle) .foregroundStyle(.blue) + .accessibilityHidden(true) Text("Enable Location") .font(.title2) diff --git a/SportsTime/Core/Services/PollService.swift b/SportsTime/Core/Services/PollService.swift index 99de32b..2684f15 100644 --- a/SportsTime/Core/Services/PollService.swift +++ b/SportsTime/Core/Services/PollService.swift @@ -52,8 +52,7 @@ actor PollService { private var pollSubscriptionID: CKSubscription.ID? private init() { - // Respect target entitlements so Debug and production stay isolated. - self.container = CKContainer.default() + self.container = CloudKitContainerConfig.makeContainer() self.publicDatabase = container.publicCloudDatabase } diff --git a/SportsTime/Core/Services/VisitPhotoService.swift b/SportsTime/Core/Services/VisitPhotoService.swift index 3ba5c46..5b08559 100644 --- a/SportsTime/Core/Services/VisitPhotoService.swift +++ b/SportsTime/Core/Services/VisitPhotoService.swift @@ -63,7 +63,7 @@ final class VisitPhotoService { init(modelContext: ModelContext) { self.modelContext = modelContext - self.container = CKContainer.default() + self.container = CloudKitContainerConfig.makeContainer() self.privateDatabase = container.privateCloudDatabase } diff --git a/SportsTime/Core/Theme/AnimatedBackground.swift b/SportsTime/Core/Theme/AnimatedBackground.swift index f76e8a8..175c7be 100644 --- a/SportsTime/Core/Theme/AnimatedBackground.swift +++ b/SportsTime/Core/Theme/AnimatedBackground.swift @@ -28,7 +28,9 @@ struct AnimatedSportsBackground: View { AnimatedSportsIcon(index: index, animate: animate) } } + .accessibilityHidden(true) .onAppear { + guard !Theme.Animation.prefersReducedMotion else { return } withAnimation(.easeInOut(duration: 5.0).repeatForever(autoreverses: true)) { animate = true } @@ -173,6 +175,8 @@ struct AnimatedSportsIcon: View { } private func triggerGlow() { + guard !Theme.Animation.prefersReducedMotion else { return } + // Slow fade in withAnimation(.easeIn(duration: 0.8)) { glowOpacity = 1 diff --git a/SportsTime/Core/Theme/AnimatedComponents.swift b/SportsTime/Core/Theme/AnimatedComponents.swift index 60fa571..8d48489 100644 --- a/SportsTime/Core/Theme/AnimatedComponents.swift +++ b/SportsTime/Core/Theme/AnimatedComponents.swift @@ -68,7 +68,9 @@ struct AnimatedRouteGraphic: View { ) } } + .accessibilityHidden(true) .onAppear { + guard !Theme.Animation.prefersReducedMotion else { return } withAnimation(.easeInOut(duration: Theme.Animation.routeDrawDuration).repeatForever(autoreverses: false)) { animationProgress = 1 } @@ -126,7 +128,9 @@ struct PulsingDot: View { .frame(width: size, height: size) .shadow(color: color.opacity(0.5), radius: 4) } + .accessibilityHidden(true) .onAppear { + guard !Theme.Animation.prefersReducedMotion else { return } withAnimation(.easeOut(duration: 1.5).repeatForever(autoreverses: false)) { isPulsing = true } @@ -171,6 +175,7 @@ struct RoutePreviewStrip: View { } .padding(.horizontal, Theme.Spacing.md) } + .accessibilityHidden(true) } private func abbreviateCity(_ city: String) -> String { @@ -196,6 +201,7 @@ struct StatPill: View { Text(value) .font(.footnote) } + .accessibilityElement(children: .combine) .foregroundStyle(Theme.textSecondary(colorScheme)) .padding(.horizontal, 12) .padding(.vertical, 6) @@ -219,6 +225,7 @@ struct EmptyStateView: View { Image(systemName: icon) .font(.largeTitle) .foregroundStyle(Theme.warmOrange.opacity(0.7)) + .accessibilityHidden(true) VStack(spacing: 8) { Text(title) diff --git a/SportsTime/Core/Theme/Loading/LoadingPlaceholder.swift b/SportsTime/Core/Theme/Loading/LoadingPlaceholder.swift index dbdcb0a..22040da 100644 --- a/SportsTime/Core/Theme/Loading/LoadingPlaceholder.swift +++ b/SportsTime/Core/Theme/Loading/LoadingPlaceholder.swift @@ -47,7 +47,9 @@ struct PlaceholderRectangle: View { .fill(placeholderColor) .frame(width: width, height: height) .opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity) + .accessibilityHidden(true) .onAppear { + guard !Theme.Animation.prefersReducedMotion else { return } withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { isAnimating = true } @@ -72,7 +74,9 @@ struct PlaceholderCircle: View { .fill(placeholderColor) .frame(width: diameter, height: diameter) .opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity) + .accessibilityHidden(true) .onAppear { + guard !Theme.Animation.prefersReducedMotion else { return } withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { isAnimating = true } @@ -98,7 +102,9 @@ struct PlaceholderCapsule: View { .fill(placeholderColor) .frame(width: width, height: height) .opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity) + .accessibilityHidden(true) .onAppear { + guard !Theme.Animation.prefersReducedMotion else { return } withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { isAnimating = true } @@ -145,7 +151,10 @@ struct PlaceholderCard: View { RoundedRectangle(cornerRadius: Theme.CornerRadius.large) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Loading content") .onAppear { + guard !Theme.Animation.prefersReducedMotion else { return } withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { isAnimating = true } @@ -185,6 +194,7 @@ struct PlaceholderListRow: View { } .padding(Theme.Spacing.md) .onAppear { + guard !Theme.Animation.prefersReducedMotion else { return } withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { isAnimating = true } diff --git a/SportsTime/Core/Theme/Loading/LoadingSheet.swift b/SportsTime/Core/Theme/Loading/LoadingSheet.swift index 8a78267..197ca0c 100644 --- a/SportsTime/Core/Theme/Loading/LoadingSheet.swift +++ b/SportsTime/Core/Theme/Loading/LoadingSheet.swift @@ -25,10 +25,11 @@ struct LoadingSheet: View { // Dimmed background Color.black.opacity(Self.backgroundOpacity) .ignoresSafeArea() + .accessibilityHidden(true) // Centered card VStack(spacing: Theme.Spacing.lg) { - LoadingSpinner(size: .large) + LoadingSpinner(size: .large, label: label) VStack(spacing: Theme.Spacing.xs) { Text(label) diff --git a/SportsTime/Core/Theme/Loading/LoadingSpinner.swift b/SportsTime/Core/Theme/Loading/LoadingSpinner.swift index b1b6057..aa5f88d 100644 --- a/SportsTime/Core/Theme/Loading/LoadingSpinner.swift +++ b/SportsTime/Core/Theme/Loading/LoadingSpinner.swift @@ -56,6 +56,7 @@ struct LoadingSpinner: View { .foregroundStyle(Theme.textSecondary(colorScheme)) } } + .accessibilityLabel(label ?? "Loading") } private var spinnerView: some View { @@ -63,15 +64,18 @@ struct LoadingSpinner: View { // Background track - subtle gray like Apple's native spinner Circle() .stroke(Color.secondary.opacity(0.2), lineWidth: size.strokeWidth) + .accessibilityHidden(true) // Rotating arc (270 degrees) - gray like Apple's ProgressView Circle() .trim(from: 0, to: 0.75) .stroke(Color.secondary, style: StrokeStyle(lineWidth: size.strokeWidth, lineCap: .round)) .rotationEffect(.degrees(rotation)) + .accessibilityHidden(true) } .frame(width: size.diameter, height: size.diameter) .onAppear { + guard !Theme.Animation.prefersReducedMotion else { return } withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { rotation = 360 } diff --git a/SportsTime/Core/Theme/SportSelectorGrid.swift b/SportsTime/Core/Theme/SportSelectorGrid.swift index 60702e0..766c250 100644 --- a/SportsTime/Core/Theme/SportSelectorGrid.swift +++ b/SportsTime/Core/Theme/SportSelectorGrid.swift @@ -90,6 +90,7 @@ struct SportActionButton: View { Image(systemName: sport.iconName) .font(.title3) .foregroundStyle(sport.themeColor) + .accessibilityHidden(true) } Text(sport.rawValue) @@ -100,13 +101,15 @@ struct SportActionButton: View { .scaleEffect(isPressed ? 0.9 : 1.0) } .buttonStyle(.plain) + .accessibilityLabel(sport.rawValue) + .accessibilityAddTraits(.isButton) .simultaneousGesture( DragGesture(minimumDistance: 0) .onChanged { _ in - withAnimation(Theme.Animation.spring) { isPressed = true } + Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = true } } .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) .font(.title3) .foregroundStyle(isSelected ? .white : sport.themeColor) + .accessibilityHidden(true) } 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)) } .frame(maxWidth: .infinity) .scaleEffect(isPressed ? 0.9 : 1.0) } .buttonStyle(.plain) + .accessibilityLabel(sport.rawValue) + .accessibilityAddTraits(.isToggle) + .accessibilityValue(isSelected ? "Selected" : "Not selected") .simultaneousGesture( DragGesture(minimumDistance: 0) .onChanged { _ in - withAnimation(Theme.Animation.spring) { isPressed = true } + Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = true } } .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) .font(.title3) .foregroundStyle(isSelected ? sport.themeColor : Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } Text(sport.rawValue) @@ -200,13 +208,17 @@ struct SportProgressButton: View { .scaleEffect(isPressed ? 0.9 : 1.0) } .buttonStyle(.plain) + .accessibilityLabel("\(sport.rawValue), \(Int(progress * 100)) percent visited") + .accessibilityValue(isSelected ? "Selected" : "Not selected") + .accessibilityAddTraits(.isButton) + .accessibilityAddTraits(isSelected ? .isSelected : []) .simultaneousGesture( DragGesture(minimumDistance: 0) .onChanged { _ in - withAnimation(Theme.Animation.spring) { isPressed = true } + Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = true } } .onEnded { _ in - withAnimation(Theme.Animation.spring) { isPressed = false } + Theme.Animation.withMotion(Theme.Animation.spring) { isPressed = false } } ) } diff --git a/SportsTime/Core/Theme/Theme.swift b/SportsTime/Core/Theme/Theme.swift index c5faa35..5e6d5b0 100644 --- a/SportsTime/Core/Theme/Theme.swift +++ b/SportsTime/Core/Theme/Theme.swift @@ -259,7 +259,15 @@ enum Theme { } 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 { @@ -288,13 +296,13 @@ enum Theme { static var darkTextMuted: Color { switch current { - case .teal: return Color(hex: "7FADA8") - case .orbit: return Color(hex: "8090A0") - case .retro: return Color(hex: "7898B8") - case .clutch: return Color(hex: "8898A8") - case .monochrome: return Color(hex: "707070") - case .sunset: return Color(hex: "9D8AA8") - case .midnight: return Color(hex: "64748B") + case .teal: return Color(hex: "B4CFCC") + case .orbit: return Color(hex: "A0B1C3") + case .retro: return Color(hex: "A4BBCF") + case .clutch: return Color(hex: "A8B7C6") + case .monochrome: return Color(hex: "B0B0B0") + case .sunset: return Color(hex: "C9B5D3") + case .midnight: return Color(hex: "A3B3C8") } } @@ -329,7 +337,15 @@ enum Theme { static var lightCardBackgroundElevated: Color { lightBackground1 } 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 { @@ -358,12 +374,12 @@ enum Theme { static var lightTextMuted: Color { switch current { - case .teal: return Color(hex: "5A9A94") - case .orbit: return Color(hex: "5A7A9A") - case .retro: return Color(hex: "5A8AAA") - case .clutch: return Color(hex: "6A7A8A") + case .teal: return Color(hex: "497F79") + case .orbit: return Color(hex: "537596") + case .retro: return Color(hex: "4A7A99") + case .clutch: return Color(hex: "5A6B7E") case .monochrome: return Color(hex: "707070") - case .sunset: return Color(hex: "9A6A5A") + case .sunset: return Color(hex: "8A5A4A") case .midnight: return Color(hex: "64748B") } } @@ -442,6 +458,20 @@ enum Theme { static var gentleSpring: SwiftUI.Animation { .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() } + } + } } } diff --git a/SportsTime/Core/Theme/ViewModifiers.swift b/SportsTime/Core/Theme/ViewModifiers.swift index 1d8c318..806670c 100644 --- a/SportsTime/Core/Theme/ViewModifiers.swift +++ b/SportsTime/Core/Theme/ViewModifiers.swift @@ -61,7 +61,10 @@ struct PressableButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .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 struct ShimmerEffect: ViewModifier { @@ -79,22 +112,25 @@ struct ShimmerEffect: ViewModifier { func body(content: Content) -> some View { content .overlay { - GeometryReader { geo in - LinearGradient( - colors: [ - .clear, - Color.white.opacity(0.3), - .clear - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: geo.size.width * 2) - .offset(x: -geo.size.width + (geo.size.width * 2 * phase)) + if !Theme.Animation.prefersReducedMotion { + GeometryReader { geo in + LinearGradient( + colors: [ + .clear, + Color.white.opacity(0.3), + .clear + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: geo.size.width * 2) + .offset(x: -geo.size.width + (geo.size.width * 2 * phase)) + } + .mask(content) } - .mask(content) } .onAppear { + guard !Theme.Animation.prefersReducedMotion else { return } withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { phase = 1 } @@ -120,8 +156,12 @@ struct StaggeredAnimation: ViewModifier { .opacity(appeared ? 1 : 0) .offset(y: appeared ? 0 : 20) .onAppear { - withAnimation(Theme.Animation.spring.delay(Double(index) * delay)) { + if Theme.Animation.prefersReducedMotion { 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 { content .background { - if DesignStyleManager.shared.animationsEnabled { + if DesignStyleManager.shared.animationsEnabled && !Theme.Animation.prefersReducedMotion { AnimatedSportsBackground() .ignoresSafeArea() } else { diff --git a/SportsTime/Export/Views/ShareButton.swift b/SportsTime/Export/Views/ShareButton.swift index 6814552..aac454f 100644 --- a/SportsTime/Export/Views/ShareButton.swift +++ b/SportsTime/Export/Views/ShareButton.swift @@ -25,6 +25,7 @@ struct ShareButton: View { switch style { case .icon: Image(systemName: "square.and.arrow.up") + .accessibilityLabel("Share") case .labeled: Label("Share", systemImage: "square.and.arrow.up") case .pill: diff --git a/SportsTime/Export/Views/SharePreviewView.swift b/SportsTime/Export/Views/SharePreviewView.swift index 6079414..e0aa884 100644 --- a/SportsTime/Export/Views/SharePreviewView.swift +++ b/SportsTime/Export/Views/SharePreviewView.swift @@ -81,6 +81,7 @@ struct SharePreviewView: View { .frame(maxHeight: 400) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) + .accessibilityLabel("Share card preview") } else if isGenerating { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .fill(Theme.cardBackground(colorScheme)) @@ -147,6 +148,9 @@ struct SharePreviewView: View { } } .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 @@ -159,6 +163,7 @@ struct SharePreviewView: View { } label: { HStack { Image(systemName: "camera.fill") + .accessibilityHidden(true) Text("Share to Instagram") } .frame(maxWidth: .infinity) @@ -175,6 +180,7 @@ struct SharePreviewView: View { } label: { HStack { Image(systemName: "doc.on.doc") + .accessibilityHidden(true) Text("Copy to Clipboard") } .frame(maxWidth: .infinity) @@ -195,6 +201,7 @@ struct SharePreviewView: View { HStack { Image(systemName: "checkmark.circle.fill") + .accessibilityHidden(true) Text("Copied to clipboard") } .padding() diff --git a/SportsTime/Features/Home/Views/HomeView.swift b/SportsTime/Features/Home/Views/HomeView.swift index dfe1bcd..644b6cc 100644 --- a/SportsTime/Features/Home/Views/HomeView.swift +++ b/SportsTime/Features/Home/Views/HomeView.swift @@ -40,6 +40,7 @@ struct HomeView: View { .font(.title2) .foregroundStyle(Theme.warmOrange) } + .accessibilityLabel("Create new trip") } } } @@ -198,6 +199,9 @@ struct HomeView: View { .font(.subheadline) .foregroundStyle(Theme.warmOrange) } + .minimumHitTarget() + .accessibilityLabel("Refresh trips") + .accessibilityHint("Fetches the latest featured trip recommendations") } .padding(.horizontal, Theme.Spacing.md) @@ -210,6 +214,7 @@ struct HomeView: View { HStack(spacing: Theme.Spacing.xs) { Image(systemName: regionGroup.region.iconName) .font(.caption) + .accessibilityHidden(true) Text(regionGroup.region.shortName) .font(.subheadline) } @@ -246,6 +251,7 @@ struct HomeView: View { HStack { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.orange) + .accessibilityLabel("Error loading trips") Text(error) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) @@ -284,6 +290,7 @@ struct HomeView: View { Text("See All") Image(systemName: "chevron.right") .font(.caption) + .accessibilityHidden(true) } .font(.subheadline) .foregroundStyle(Theme.warmOrange) @@ -348,7 +355,9 @@ struct SavedTripCard: View { Image(systemName: "map.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { Text(trip.displayName) @@ -363,11 +372,13 @@ struct SavedTripCard: View { HStack(spacing: 4) { Image(systemName: "mappin") .font(.caption2) + .accessibilityHidden(true) Text("\(trip.stops.count) cities") } HStack(spacing: 4) { Image(systemName: "sportscourt") .font(.caption2) + .accessibilityHidden(true) Text("\(trip.totalGames) games") } } @@ -380,6 +391,7 @@ struct SavedTripCard: View { Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) @@ -388,6 +400,7 @@ struct SavedTripCard: View { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) } + .accessibilityElement(children: .combine) } .buttonStyle(.plain) } @@ -410,6 +423,7 @@ struct TipRow: View { .font(.subheadline) .foregroundStyle(Theme.routeGold) } + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(title) @@ -496,6 +510,7 @@ struct SavedTripsListView: View { Image(systemName: "plus.circle") .foregroundStyle(Theme.warmOrange) } + .accessibilityLabel("Create poll") } } @@ -522,6 +537,7 @@ struct SavedTripsListView: View { Image(systemName: "person.3") .font(.title) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) Text("No group polls yet") .font(.subheadline) @@ -563,6 +579,7 @@ struct SavedTripsListView: View { Image(systemName: "suitcase") .font(.largeTitle) .foregroundColor(.secondary) + .accessibilityHidden(true) Text("No Saved Trips") .font(.headline) @@ -621,7 +638,9 @@ private struct PollRowCard: View { Image(systemName: "chart.bar.doc.horizontal") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } + .accessibilityHidden(true) VStack(alignment: .leading, spacing: Theme.Spacing.xs) { Text(poll.title) @@ -644,6 +663,7 @@ private struct PollRowCard: View { Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) @@ -676,6 +696,7 @@ struct SavedTripListRow: View { } } .frame(width: 20) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: Theme.Spacing.xs) { Text(trip.displayName) @@ -703,6 +724,7 @@ struct SavedTripListRow: View { Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } .padding(Theme.Spacing.lg) .background(Theme.cardBackground(colorScheme)) @@ -712,6 +734,7 @@ struct SavedTripListRow: View { .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) } .shadow(color: Theme.cardShadow(colorScheme), radius: 8, y: 4) + .accessibilityElement(children: .combine) } } @@ -733,9 +756,11 @@ struct ProLockedView: View { .frame(width: 100, height: 100) Image(systemName: "lock.fill") - .font(.system(size: 40)) + .font(.largeTitle) .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } + .accessibilityLabel("Pro feature locked") VStack(spacing: Theme.Spacing.sm) { Text(feature.displayName) @@ -762,6 +787,7 @@ struct ProLockedView: View { .background(Theme.warmOrange) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .accessibilityElement(children: .combine) } .padding(.horizontal, Theme.Spacing.xl) diff --git a/SportsTime/Features/Home/Views/SuggestedTripCard.swift b/SportsTime/Features/Home/Views/SuggestedTripCard.swift index e00b896..48b466f 100644 --- a/SportsTime/Features/Home/Views/SuggestedTripCard.swift +++ b/SportsTime/Features/Home/Views/SuggestedTripCard.swift @@ -32,6 +32,7 @@ struct SuggestedTripCard: View { Image(systemName: sport.iconName) .font(.caption) .foregroundStyle(sport.themeColor) + .accessibilityHidden(true) } } } @@ -76,12 +77,14 @@ struct SuggestedTripCard: View { .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) } .shadow(color: Theme.cardShadow(colorScheme), radius: 8, y: 4) + .accessibilityElement(children: .combine) } private var routePreview: some View { let cities = suggestedTrip.trip.stops.map { $0.city } let startCity = cities.first ?? "" let endCity = cities.last ?? "" + let routeDescription = cities.joined(separator: " to ") return VStack(alignment: .leading, spacing: Theme.Spacing.xs) { // Start → End display @@ -94,6 +97,7 @@ struct SuggestedTripCard: View { Image(systemName: "arrow.right") .font(.caption2) .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text(endCity) .font(.subheadline) @@ -108,11 +112,13 @@ struct SuggestedTripCard: View { Circle() .fill(index == 0 || index == cities.count - 1 ? Theme.warmOrange : Theme.routeGold.opacity(0.6)) .frame(width: 6, height: 6) + .accessibilityHidden(true) if index < cities.count - 1 { Rectangle() .fill(Theme.routeGold.opacity(0.4)) .frame(width: 8, height: 2) + .accessibilityHidden(true) } } } @@ -120,6 +126,8 @@ struct SuggestedTripCard: View { } .frame(height: 12) } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Route: \(routeDescription)") } private var regionColor: Color { diff --git a/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_Classic.swift b/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_Classic.swift index 90a1548..8c0b349 100644 --- a/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_Classic.swift +++ b/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_Classic.swift @@ -118,6 +118,8 @@ struct HomeContent_Classic: View { .font(.subheadline) .foregroundStyle(Theme.warmOrange) } + .minimumHitTarget() + .accessibilityLabel("Refresh trips") } .padding(.horizontal, Theme.Spacing.md) @@ -130,6 +132,7 @@ struct HomeContent_Classic: View { HStack(spacing: Theme.Spacing.xs) { Image(systemName: regionGroup.region.iconName) .font(.caption) + .accessibilityHidden(true) Text(regionGroup.region.shortName) .font(.subheadline) } @@ -162,6 +165,7 @@ struct HomeContent_Classic: View { HStack { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.orange) + .accessibilityLabel("Error loading trips") Text(error) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) @@ -200,6 +204,7 @@ struct HomeContent_Classic: View { Text("See All") Image(systemName: "chevron.right") .font(.caption) + .accessibilityHidden(true) } .font(.subheadline) .foregroundStyle(Theme.warmOrange) @@ -230,7 +235,9 @@ struct HomeContent_Classic: View { Image(systemName: "map.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { Text(trip.displayName) @@ -245,11 +252,13 @@ struct HomeContent_Classic: View { HStack(spacing: 4) { Image(systemName: "mappin") .font(.caption2) + .accessibilityHidden(true) Text("\(trip.stops.count) cities") } HStack(spacing: 4) { Image(systemName: "sportscourt") .font(.caption2) + .accessibilityHidden(true) Text("\(trip.totalGames) games") } } @@ -262,6 +271,7 @@ struct HomeContent_Classic: View { Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) @@ -270,6 +280,7 @@ struct HomeContent_Classic: View { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) } + .accessibilityElement(children: .combine) } // MARK: - Tips Section @@ -306,6 +317,7 @@ struct HomeContent_Classic: View { .font(.subheadline) .foregroundStyle(Theme.routeGold) } + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(title) diff --git a/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift b/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift index 2def6f4..d4ed273 100644 --- a/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift +++ b/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift @@ -117,6 +117,8 @@ struct HomeContent_ClassicAnimated: View { .font(.subheadline) .foregroundStyle(Theme.warmOrange) } + .minimumHitTarget() + .accessibilityLabel("Refresh trips") } .padding(.horizontal, Theme.Spacing.md) @@ -129,6 +131,7 @@ struct HomeContent_ClassicAnimated: View { HStack(spacing: Theme.Spacing.xs) { Image(systemName: regionGroup.region.iconName) .font(.caption) + .accessibilityHidden(true) Text(regionGroup.region.shortName) .font(.subheadline) } @@ -161,6 +164,7 @@ struct HomeContent_ClassicAnimated: View { HStack { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.orange) + .accessibilityLabel("Error loading trips") Text(error) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) @@ -199,6 +203,7 @@ struct HomeContent_ClassicAnimated: View { Text("See All") Image(systemName: "chevron.right") .font(.caption) + .accessibilityHidden(true) } .font(.subheadline) .foregroundStyle(Theme.warmOrange) @@ -229,7 +234,9 @@ struct HomeContent_ClassicAnimated: View { Image(systemName: "map.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { Text(trip.displayName) @@ -244,11 +251,13 @@ struct HomeContent_ClassicAnimated: View { HStack(spacing: 4) { Image(systemName: "mappin") .font(.caption2) + .accessibilityHidden(true) Text("\(trip.stops.count) cities") } HStack(spacing: 4) { Image(systemName: "sportscourt") .font(.caption2) + .accessibilityHidden(true) Text("\(trip.totalGames) games") } } @@ -261,6 +270,7 @@ struct HomeContent_ClassicAnimated: View { Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) @@ -269,6 +279,7 @@ struct HomeContent_ClassicAnimated: View { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) } + .accessibilityElement(children: .combine) } // MARK: - Tips Section @@ -305,6 +316,7 @@ struct HomeContent_ClassicAnimated: View { .font(.subheadline) .foregroundStyle(Theme.routeGold) } + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(title) diff --git a/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift b/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift index 9f71ee0..fba831f 100644 --- a/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift +++ b/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift @@ -27,6 +27,8 @@ struct ProGateModifier: ViewModifier { .onTapGesture { showPaywall = true } + .accessibilityLabel("Pro feature locked") + .accessibilityHint("Double-tap to view upgrade options") } } .sheet(isPresented: $showPaywall) { diff --git a/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift b/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift index 4d7ecd2..9e4f7a7 100644 --- a/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift +++ b/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift @@ -96,8 +96,10 @@ struct TripRoutesBackground: View { // Animated car/plane icon traveling along a route TravelingIcon(color: color, animate: animate) } + .accessibilityHidden(true) .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 } } @@ -115,13 +117,15 @@ private struct TravelingIcon: View { Image(systemName: "car.fill") .font(.system(size: 14)) .foregroundStyle(color.opacity(0.3)) + .accessibilityHidden(true) .position( x: geo.size.width * (0.15 + position * 0.7), y: geo.size.height * (0.2 + sin(position * .pi * 2) * 0.15) ) } .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 } } @@ -176,6 +180,7 @@ struct DocumentsBackground: View { ForEach(0..<12, id: \.self) { index in documentIcon(index: index) } + .accessibilityHidden(true) // Central PDF badge GeometryReader { geo in @@ -196,8 +201,10 @@ struct DocumentsBackground: View { .scaleEffect(animate ? 1.05 : 0.95) } } + .accessibilityHidden(true) .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 } } @@ -251,7 +258,7 @@ struct StadiumMapBackground: View { var body: some View { ZStack { - // Map grid canvas + // Map grid canvas (decorative) Canvas { context, size in // Draw subtle grid lines like a map let gridSpacing: CGFloat = 35 @@ -357,11 +364,13 @@ struct StadiumMapBackground: View { .position(x: geo.size.width * 0.5, y: geo.size.height * 0.92) } } + .accessibilityHidden(true) .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 } - 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 } } @@ -459,7 +468,10 @@ struct OnboardingPaywallView: View { .tag(pages.count) } .tabViewStyle(.page(indexDisplayMode: .never)) - .animation(.easeInOut, value: currentPage) + .animation( + Theme.Animation.prefersReducedMotion ? nil : .easeInOut, + value: currentPage + ) // Page indicator HStack(spacing: 8) { @@ -500,8 +512,9 @@ struct OnboardingPaywallView: View { .frame(width: 100, height: 100) Image(systemName: page.icon) - .font(.system(size: 44)) + .font(.largeTitle) .foregroundStyle(page.color) + .accessibilityHidden(true) } VStack(spacing: Theme.Spacing.sm) { @@ -521,6 +534,7 @@ struct OnboardingPaywallView: View { Image(systemName: "checkmark.circle.fill") .foregroundStyle(page.color) .font(.body) + .accessibilityHidden(true) Text(bullet) .font(.body) @@ -582,6 +596,7 @@ struct OnboardingPaywallView: View { .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } + .accessibilityHint("Page \(currentPage + 1) of \(pages.count + 1)") } // Continue free (always visible) @@ -594,6 +609,7 @@ struct OnboardingPaywallView: View { .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) } + .accessibilityHint("Skip and continue with free version") } } diff --git a/SportsTime/Features/Paywall/Views/PaywallView.swift b/SportsTime/Features/Paywall/Views/PaywallView.swift index fafffb9..3ecce52 100644 --- a/SportsTime/Features/Paywall/Views/PaywallView.swift +++ b/SportsTime/Features/Paywall/Views/PaywallView.swift @@ -23,8 +23,9 @@ struct PaywallView: View { SubscriptionStoreView(subscriptions: storeManager.products.filter { $0.subscription != nil }) { VStack(spacing: Theme.Spacing.md) { Image(systemName: "star.circle.fill") - .font(.system(size: 60)) + .font(.system(.largeTitle, design: .default).weight(.regular)) .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text("Upgrade to Pro") .font(.largeTitle.bold()) @@ -79,10 +80,12 @@ struct PaywallView: View { private func featurePill(icon: String, text: String) -> some View { HStack(spacing: 4) { Image(systemName: icon) - .font(.system(size: 11)) + .font(.caption2) + .accessibilityHidden(true) Text(text) .font(.caption2) } + .accessibilityElement(children: .combine) .foregroundStyle(Theme.textMuted(colorScheme)) .padding(.horizontal, 10) .padding(.vertical, 6) diff --git a/SportsTime/Features/Paywall/Views/ProBadge.swift b/SportsTime/Features/Paywall/Views/ProBadge.swift index fee5aaf..cdf713c 100644 --- a/SportsTime/Features/Paywall/Views/ProBadge.swift +++ b/SportsTime/Features/Paywall/Views/ProBadge.swift @@ -15,6 +15,7 @@ struct ProBadge: View { .padding(.horizontal, 6) .padding(.vertical, 2) .background(Theme.warmOrange, in: Capsule()) + .accessibilityLabel("Pro feature") } } diff --git a/SportsTime/Features/Polls/ViewModels/PollVotingViewModel.swift b/SportsTime/Features/Polls/ViewModels/PollVotingViewModel.swift index d7220d2..b501d58 100644 --- a/SportsTime/Features/Polls/ViewModels/PollVotingViewModel.swift +++ b/SportsTime/Features/Polls/ViewModels/PollVotingViewModel.swift @@ -35,6 +35,16 @@ final class PollVotingViewModel { 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 { guard canSubmit else { return } diff --git a/SportsTime/Features/Polls/Views/PollCreationView.swift b/SportsTime/Features/Polls/Views/PollCreationView.swift index 63bff3b..6889c7e 100644 --- a/SportsTime/Features/Polls/Views/PollCreationView.swift +++ b/SportsTime/Features/Polls/Views/PollCreationView.swift @@ -21,6 +21,7 @@ struct PollCreationView: View { Section { TextField("Poll Title", text: $viewModel.title) .textInputAutocapitalization(.words) + .accessibilityHint("Enter a descriptive name for your poll") } header: { Text("Title") } footer: { @@ -115,10 +116,13 @@ private struct TripSelectionRow: View { Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .font(.title2) .foregroundStyle(isSelected ? Theme.warmOrange : .secondary) + .accessibilityHidden(true) } .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityValue(isSelected ? "Selected" : "Not selected") + .accessibilityAddTraits(isSelected ? .isSelected : []) } private var tripSummary: String { diff --git a/SportsTime/Features/Polls/Views/PollDetailView.swift b/SportsTime/Features/Polls/Views/PollDetailView.swift index d84747c..f562479 100644 --- a/SportsTime/Features/Polls/Views/PollDetailView.swift +++ b/SportsTime/Features/Polls/Views/PollDetailView.swift @@ -77,6 +77,8 @@ struct PollDetailView: View { } } label: { Image(systemName: "ellipsis.circle") + .minimumHitTarget() + .accessibilityLabel("More options") } } } @@ -172,7 +174,7 @@ struct PollDetailView: View { .fill(Theme.warmOrange.opacity(0.15)) .frame(width: 56, height: 56) Image(systemName: "link.circle.fill") - .font(.system(size: 28)) + .font(.title2) .foregroundStyle(Theme.warmOrange) } @@ -182,9 +184,11 @@ struct PollDetailView: View { .foregroundStyle(Theme.textSecondary(colorScheme)) Text(poll.shareCode) - .font(.system(size: 36, weight: .bold, design: .monospaced)) + .font(.system(.largeTitle, design: .monospaced).weight(.bold)) .foregroundStyle(Theme.warmOrange) .tracking(4) + .lineLimit(1) + .minimumScaleFactor(0.7) } // Copy button @@ -221,6 +225,7 @@ struct PollDetailView: View { Image(systemName: viewModel.hasVoted ? "checkmark.circle.fill" : "hand.raised.fill") .font(.title3) .foregroundStyle(viewModel.hasVoted ? Theme.mlsGreen : Theme.warmOrange) + .accessibilityLabel(viewModel.hasVoted ? "You have voted" : "You have not voted") } VStack(alignment: .leading, spacing: 2) { @@ -231,6 +236,7 @@ struct PollDetailView: View { HStack(spacing: Theme.Spacing.xs) { Image(systemName: "person.2.fill") .font(.caption2) + .accessibilityHidden(true) Text("\(viewModel.votes.count) vote\(viewModel.votes.count == 1 ? "" : "s")") .font(.subheadline) } @@ -263,6 +269,7 @@ struct PollDetailView: View { HStack(spacing: Theme.Spacing.sm) { Image(systemName: "chart.bar.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text("Results") .font(.headline) .foregroundStyle(Theme.textPrimary(colorScheme)) @@ -294,6 +301,7 @@ struct PollDetailView: View { HStack(spacing: Theme.Spacing.sm) { Image(systemName: "map.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text("Trip Options") .font(.headline) .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 { switch rank { case 1: return Theme.warmOrange @@ -366,6 +395,7 @@ private struct ResultRow: View { Image(systemName: rankIcon) .font(.subheadline) .foregroundStyle(rankColor) + .accessibilityLabel(rankAccessibilityLabel) } VStack(alignment: .leading, spacing: 4) { @@ -447,6 +477,7 @@ private struct TripPreviewCard: View { .font(.caption) .fontWeight(.semibold) .foregroundStyle(.tertiary) + .accessibilityHidden(true) } .padding() .background(Theme.cardBackground(colorScheme)) diff --git a/SportsTime/Features/Polls/Views/PollVotingView.swift b/SportsTime/Features/Polls/Views/PollVotingView.swift index 1831e3b..5d4a6a4 100644 --- a/SportsTime/Features/Polls/Views/PollVotingView.swift +++ b/SportsTime/Features/Polls/Views/PollVotingView.swift @@ -27,8 +27,13 @@ struct PollVotingView: View { ForEach(Array(viewModel.rankings.enumerated()), id: \.element) { index, tripIndex in RankingRow( 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 viewModel.moveTrip(from: source, to: destination) @@ -79,11 +84,12 @@ struct PollVotingView: View { Image(systemName: "arrow.up.arrow.down") .font(.title2) .foregroundStyle(Theme.warmOrange) + .accessibilityLabel("Drag to reorder") Text("Drag to rank your preferences") .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) .foregroundStyle(.secondary) } @@ -126,6 +132,10 @@ struct PollVotingView: View { private struct RankingRow: View { let rank: Int let trip: Trip + let canMoveUp: Bool + let canMoveDown: Bool + let onMoveUp: () -> Void + let onMoveDown: () -> Void var body: some View { HStack(spacing: 12) { @@ -147,6 +157,25 @@ private struct RankingRow: View { } 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) } diff --git a/SportsTime/Features/Polls/Views/PollsListView.swift b/SportsTime/Features/Polls/Views/PollsListView.swift index c0ed744..7e04f92 100644 --- a/SportsTime/Features/Polls/Views/PollsListView.swift +++ b/SportsTime/Features/Polls/Views/PollsListView.swift @@ -33,6 +33,7 @@ struct PollsListView: View { showJoinPoll = true } label: { Image(systemName: "link.badge.plus") + .accessibilityLabel("Join a poll") } } } diff --git a/SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift b/SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift index 7d35fb2..c812093 100644 --- a/SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift +++ b/SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift @@ -26,7 +26,7 @@ final class MapInteractionViewModel { } func resetToDefault() { - withAnimation(.easeInOut(duration: 0.5)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.5)) { region = MapInteractionViewModel.defaultRegion } hasUserInteracted = false @@ -34,7 +34,7 @@ final class MapInteractionViewModel { } func zoomToStadium(at coordinate: CLLocationCoordinate2D) { - withAnimation(.easeInOut(duration: 0.3)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.3)) { region = MKCoordinateRegion( center: coordinate, span: MapInteractionViewModel.stadiumZoomSpan diff --git a/SportsTime/Features/Progress/Views/AchievementsListView.swift b/SportsTime/Features/Progress/Views/AchievementsListView.swift index 3ceceaa..c5d6471 100644 --- a/SportsTime/Features/Progress/Views/AchievementsListView.swift +++ b/SportsTime/Features/Progress/Views/AchievementsListView.swift @@ -115,14 +115,16 @@ struct AchievementsListView: View { .frame(width: 64, height: 64) Image(systemName: selectedSport?.iconName ?? "trophy.fill") - .font(.system(size: 28)) + .font(.title2) .foregroundStyle(earned > 0 ? completedGold : accentColor) + .accessibilityHidden(true) } VStack(alignment: .leading, spacing: Theme.Spacing.xs) { HStack(alignment: .firstTextBaseline, spacing: 4) { 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)) Text("/ \(total)") .font(.title2) @@ -174,7 +176,7 @@ struct AchievementsListView: View { color: Theme.warmOrange, isSelected: selectedSport == nil ) { - withAnimation(Theme.Animation.spring) { + Theme.Animation.withMotion(Theme.Animation.spring) { selectedSport = nil } } @@ -187,7 +189,7 @@ struct AchievementsListView: View { color: sport.themeColor, isSelected: selectedSport == sport ) { - withAnimation(Theme.Animation.spring) { + Theme.Animation.withMotion(Theme.Animation.spring) { selectedSport = sport } } @@ -287,6 +289,8 @@ struct SportFilterButton: View { } } .buttonStyle(.plain) + .accessibilityValue(isSelected ? "Selected" : "Not selected") + .accessibilityAddTraits(isSelected ? .isSelected : []) } } @@ -318,8 +322,9 @@ struct AchievementCard: View { } Image(systemName: achievement.definition.iconName) - .font(.system(size: 28)) + .font(.title2) .foregroundStyle(badgeIconColor) + .accessibilityHidden(true) if !achievement.isEarned { Circle() @@ -329,6 +334,7 @@ struct AchievementCard: View { Image(systemName: "lock.fill") .font(.subheadline) .foregroundStyle(.white) + .accessibilityHidden(true) } } @@ -346,6 +352,7 @@ struct AchievementCard: View { HStack(spacing: 4) { Image(systemName: "checkmark.seal.fill") .font(.caption) + .accessibilityHidden(true) if let earnedAt = achievement.earnedAt { Text(earnedAt.formatted(date: .abbreviated, time: .omitted)) } 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) .opacity(achievement.isEarned ? 1.0 : 0.7) + .accessibilityElement(children: .combine) } private var badgeBackgroundColor: Color { @@ -492,8 +500,9 @@ struct AchievementDetailSheet: View { } Image(systemName: achievement.definition.iconName) - .font(.system(size: 56)) + .font(.largeTitle) .foregroundStyle(badgeIconColor) + .accessibilityHidden(true) if !achievement.isEarned { Circle() @@ -501,8 +510,9 @@ struct AchievementDetailSheet: View { .frame(width: 120, height: 120) Image(systemName: "lock.fill") - .font(.system(size: 24)) + .font(.title3) .foregroundStyle(.white) + .accessibilityHidden(true) } } @@ -538,8 +548,9 @@ struct AchievementDetailSheet: View { if achievement.isEarned { VStack(spacing: 8) { Image(systemName: "checkmark.seal.fill") - .font(.system(size: 32)) + .font(.title) .foregroundStyle(completedGold) + .accessibilityHidden(true) if let earnedAt = achievement.earnedAt { Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))") @@ -575,6 +586,7 @@ struct AchievementDetailSheet: View { if let sport = achievement.definition.sport { HStack(spacing: Theme.Spacing.xs) { Image(systemName: sport.iconName) + .accessibilityLabel(sport.displayName) Text(sport.displayName) } .font(.subheadline) diff --git a/SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift b/SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift index a972142..494ef1b 100644 --- a/SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift +++ b/SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift @@ -12,6 +12,7 @@ struct GamesHistoryRow: View { .font(.title3) .foregroundStyle(stadium.sport.themeColor) .frame(width: 32) + .accessibilityHidden(true) } // Visit info @@ -38,10 +39,12 @@ struct GamesHistoryRow: View { Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) + .accessibilityHidden(true) } .padding() .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 10)) + .accessibilityElement(children: .combine) } private func sportIcon(for sport: Sport) -> String { diff --git a/SportsTime/Features/Progress/Views/Components/VisitListCard.swift b/SportsTime/Features/Progress/Views/Components/VisitListCard.swift index 34111b2..f57e381 100644 --- a/SportsTime/Features/Progress/Views/Components/VisitListCard.swift +++ b/SportsTime/Features/Progress/Views/Components/VisitListCard.swift @@ -8,7 +8,7 @@ struct VisitListCard: View { VStack(alignment: .leading, spacing: 0) { // Header row (always visible) Button { - withAnimation(.easeInOut(duration: 0.2)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { isExpanded.toggle() } } label: { @@ -37,11 +37,13 @@ struct VisitListCard: View { .font(.caption) .foregroundStyle(.secondary) .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .accessibilityLabel(isExpanded ? "Collapse details" : "Expand details") } .padding() .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityHint("Double-tap to expand visit details") // Expanded content if isExpanded { @@ -115,6 +117,7 @@ private struct InfoRow: View { .font(.caption) .foregroundStyle(.secondary) .frame(width: 16) + .accessibilityHidden(true) Text(label) .font(.caption) diff --git a/SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift b/SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift index 1bff8c0..87d14a5 100644 --- a/SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift +++ b/SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift @@ -107,6 +107,7 @@ struct GameMatchConfirmationView: View { HStack { Image(systemName: "mappin.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text("Nearest Stadium") .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) @@ -131,6 +132,7 @@ struct GameMatchConfirmationView: View { Text(match.formattedDistance) .font(.subheadline) .foregroundStyle(confidenceColor(match.confidence)) + .accessibilityLabel("\(match.formattedDistance), \(match.confidence.description) confidence") Text(match.confidence.description) .font(.caption2) @@ -154,6 +156,7 @@ struct GameMatchConfirmationView: View { HStack { Image(systemName: "sportscourt.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text(matchOptionsTitle) .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) @@ -196,6 +199,9 @@ struct GameMatchConfirmationView: View { } label: { 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) .font(.caption) .foregroundStyle(match.game.sport.themeColor) + .accessibilityHidden(true) } Text(match.gameDateTime) @@ -233,6 +240,7 @@ struct GameMatchConfirmationView: View { Circle() .fill(combinedConfidenceColor(match.confidence.combined)) .frame(width: 8, height: 8) + .accessibilityLabel(confidenceAccessibilityLabel(match.confidence.combined)) Text(match.confidence.combined.description) .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) @@ -245,6 +253,7 @@ struct GameMatchConfirmationView: View { Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .font(.title2) .foregroundStyle(isSelected ? .green : Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } .padding(Theme.Spacing.md) .background(isSelected ? Theme.cardBackgroundElevated(colorScheme) : Color.clear) @@ -318,6 +327,14 @@ struct GameMatchConfirmationView: View { 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 diff --git a/SportsTime/Features/Progress/Views/GamesHistoryView.swift b/SportsTime/Features/Progress/Views/GamesHistoryView.swift index 0f39a8a..cf387d7 100644 --- a/SportsTime/Features/Progress/Views/GamesHistoryView.swift +++ b/SportsTime/Features/Progress/Views/GamesHistoryView.swift @@ -57,6 +57,7 @@ private struct GamesHistoryContent: View { viewModel.clearFilters() } .font(.caption) + .accessibilityHint("Clear all sport filters") } } @@ -119,6 +120,7 @@ private struct SportChip: View { HStack(spacing: 4) { Image(systemName: sportIcon) .font(.caption) + .accessibilityHidden(true) Text(sport.rawValue) .font(.caption.bold()) } @@ -131,6 +133,8 @@ private struct SportChip: View { ) } .buttonStyle(.plain) + .accessibilityValue(isSelected ? "Selected" : "Not selected") + .accessibilityAddTraits(isSelected ? .isSelected : []) } private var sportIcon: String { @@ -199,7 +203,7 @@ private struct EmptyGamesView: View { var body: some View { VStack(spacing: 16) { Image(systemName: "ticket") - .font(.system(size: 48)) + .font(.largeTitle) .foregroundStyle(.secondary) Text("No games recorded yet") diff --git a/SportsTime/Features/Progress/Views/PhotoImportView.swift b/SportsTime/Features/Progress/Views/PhotoImportView.swift index 81d38fe..1598eda 100644 --- a/SportsTime/Features/Progress/Views/PhotoImportView.swift +++ b/SportsTime/Features/Progress/Views/PhotoImportView.swift @@ -91,7 +91,7 @@ struct PhotoImportView: View { .frame(width: 120, height: 120) Image(systemName: "photo.on.rectangle.angled") - .font(.system(size: 50)) + .font(.largeTitle) .foregroundStyle(Theme.warmOrange) } @@ -137,6 +137,7 @@ struct PhotoImportView: View { HStack { Image(systemName: "info.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text("How it works") .font(.body) } @@ -374,6 +375,7 @@ struct PhotoImportCandidateCard: View { .font(.title2) .foregroundStyle(isConfirmed ? .green : Theme.textMuted(colorScheme)) } + .accessibilityLabel(isConfirmed ? "Deselect for import" : "Confirm import") } .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) @@ -419,6 +421,7 @@ struct PhotoImportCandidateCard: View { Spacer() Image(systemName: "chevron.right") .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } } @@ -426,6 +429,7 @@ struct PhotoImportCandidateCard: View { HStack { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.red) + .accessibilityLabel("Error") Text(reason.description) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) @@ -538,6 +542,7 @@ private struct InfoRow: View { HStack(spacing: 8) { Image(systemName: icon) .frame(width: 16) + .accessibilityHidden(true) Text(text) } } diff --git a/SportsTime/Features/Progress/Views/ProgressMapView.swift b/SportsTime/Features/Progress/Views/ProgressMapView.swift index 895acf4..0542b78 100644 --- a/SportsTime/Features/Progress/Views/ProgressMapView.swift +++ b/SportsTime/Features/Progress/Views/ProgressMapView.swift @@ -33,7 +33,7 @@ struct ProgressMapView: View { isVisited: isVisited(stadium), isSelected: selectedStadium?.id == stadium.id, onTap: { - withAnimation(.spring(response: 0.3)) { + Theme.Animation.withMotion(.spring(response: 0.3)) { if selectedStadium?.id == stadium.id { selectedStadium = nil } else { @@ -51,7 +51,7 @@ struct ProgressMapView: View { .overlay(alignment: .bottomTrailing) { if mapViewModel.shouldShowResetButton { Button { - withAnimation(.easeInOut(duration: 0.5)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.5)) { cameraPosition = .region(MapInteractionViewModel.defaultRegion) mapViewModel.resetToDefault() selectedStadium = nil @@ -108,6 +108,7 @@ struct StadiumMapPin: View { .fill(pinColor) .frame(width: 10, height: 6) .offset(y: -2) + .accessibilityHidden(true) // Stadium name (when selected) if isSelected { @@ -128,7 +129,10 @@ struct StadiumMapPin: View { } } .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 { diff --git a/SportsTime/Features/Progress/Views/ProgressTabView.swift b/SportsTime/Features/Progress/Views/ProgressTabView.swift index 4640ce9..cd0a843 100644 --- a/SportsTime/Features/Progress/Views/ProgressTabView.swift +++ b/SportsTime/Features/Progress/Views/ProgressTabView.swift @@ -63,6 +63,7 @@ struct ProgressTabView: View { .font(.title2) .foregroundStyle(Theme.warmOrange) } + .accessibilityLabel("Add stadium visit") } } .task { @@ -153,7 +154,7 @@ struct ProgressTabView: View { isSelected: viewModel.selectedSport == sport, progress: progressForSport(sport) ) { - withAnimation(Theme.Animation.spring) { + Theme.Animation.withMotion(Theme.Animation.spring) { viewModel.selectSport(sport) } } @@ -180,13 +181,18 @@ struct ProgressTabView: View { Circle() .stroke(Theme.warmOrange.opacity(0.2), lineWidth: 8) .frame(width: 80, height: 80) + .accessibilityHidden(true) Circle() .trim(from: 0, to: progress.progressFraction) .stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round)) .frame(width: 80, height: 80) .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) { Text("\(progress.visitedStadiums)") @@ -265,6 +271,7 @@ struct ProgressTabView: View { HStack { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) + .accessibilityHidden(true) Text("Visited (\(viewModel.visitedStadiums.count))") .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) @@ -292,6 +299,7 @@ struct ProgressTabView: View { HStack { Image(systemName: "circle.dotted") .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) Text("Not Yet Visited (\(viewModel.unvisitedStadiums.count))") .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) @@ -331,6 +339,7 @@ struct ProgressTabView: View { HStack(spacing: 4) { Text("View All") Image(systemName: "chevron.right") + .accessibilityHidden(true) } .font(.subheadline) .foregroundStyle(Theme.warmOrange) @@ -348,8 +357,9 @@ struct ProgressTabView: View { .frame(width: 50, height: 50) Image(systemName: "trophy.fill") - .font(.system(size: 24)) + .font(.title3) .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } VStack(alignment: .leading, spacing: 4) { @@ -366,6 +376,7 @@ struct ProgressTabView: View { Image(systemName: "chevron.right") .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) @@ -396,6 +407,7 @@ struct ProgressTabView: View { HStack(spacing: 4) { Text("See All") Image(systemName: "chevron.right") + .accessibilityHidden(true) } .font(.subheadline) .foregroundStyle(Theme.warmOrange) @@ -432,6 +444,7 @@ struct ProgressStatPill: View { HStack(spacing: 4) { Image(systemName: icon) .font(.caption) + .accessibilityHidden(true) Text(value) .font(.body) } @@ -460,6 +473,7 @@ struct StadiumChip: View { Image(systemName: "checkmark.circle.fill") .font(.caption) .foregroundStyle(.green) + .accessibilityHidden(true) } VStack(alignment: .leading, spacing: 2) { @@ -495,6 +509,7 @@ struct StadiumChip: View { } } .buttonStyle(.plain) + .accessibilityElement(children: .combine) } } @@ -513,6 +528,7 @@ struct RecentVisitRow: View { Image(systemName: visit.sport.iconName) .foregroundStyle(visit.sport.themeColor) + .accessibilityHidden(true) } VStack(alignment: .leading, spacing: 4) { @@ -538,6 +554,7 @@ struct RecentVisitRow: View { Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) @@ -546,6 +563,7 @@ struct RecentVisitRow: View { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) } + .accessibilityElement(children: .combine) } } diff --git a/SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift b/SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift index e298fb4..de14cf6 100644 --- a/SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift +++ b/SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift @@ -34,6 +34,7 @@ struct StadiumVisitHistoryView: View { } label: { Image(systemName: "plus") } + .accessibilityLabel("Add visit to this stadium") } } .sheet(isPresented: $showingAddVisit) { @@ -93,7 +94,7 @@ private struct EmptyVisitHistoryView: View { var body: some View { VStack(spacing: 16) { Image(systemName: "calendar.badge.plus") - .font(.system(size: 48)) + .font(.largeTitle) .foregroundStyle(.secondary) Text("No visits recorded") diff --git a/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift b/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift index 86ca077..d4f1f94 100644 --- a/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift +++ b/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift @@ -165,6 +165,7 @@ struct StadiumVisitSheet: View { .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(Capsule()) } + .accessibilityLabel("Select team \(team.name)") } } .padding(.top, 8) @@ -201,6 +202,7 @@ struct StadiumVisitSheet: View { .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(Capsule()) } + .accessibilityLabel("Select team \(team.name)") } } .padding(.top, 8) @@ -283,6 +285,7 @@ struct StadiumVisitSheet: View { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) + .accessibilityHidden(true) Text(error) .foregroundStyle(.red) } diff --git a/SportsTime/Features/Progress/Views/VisitDetailView.swift b/SportsTime/Features/Progress/Views/VisitDetailView.swift index 31082af..4e8fb14 100644 --- a/SportsTime/Features/Progress/Views/VisitDetailView.swift +++ b/SportsTime/Features/Progress/Views/VisitDetailView.swift @@ -149,6 +149,7 @@ struct VisitDetailView: View { Image(systemName: visit.sportEnum?.iconName ?? "sportscourt") .font(.largeTitle) .foregroundStyle(sportColor) + .accessibilityLabel(visit.sportEnum?.displayName ?? "Sport") } VStack(spacing: Theme.Spacing.xs) { @@ -188,6 +189,7 @@ struct VisitDetailView: View { HStack { Image(systemName: "sportscourt.fill") .foregroundStyle(sportColor) + .accessibilityHidden(true) Text("Game Info") .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) @@ -232,6 +234,7 @@ struct VisitDetailView: View { HStack { Image(systemName: "info.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text("Details") .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) @@ -292,6 +295,7 @@ struct VisitDetailView: View { HStack { Image(systemName: "note.text") .foregroundStyle(Theme.routeGold) + .accessibilityHidden(true) Text("Notes") .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) diff --git a/SportsTime/Features/Schedule/Views/ScheduleListView.swift b/SportsTime/Features/Schedule/Views/ScheduleListView.swift index f1ecd8d..0ee1189 100644 --- a/SportsTime/Features/Schedule/Views/ScheduleListView.swift +++ b/SportsTime/Features/Schedule/Views/ScheduleListView.swift @@ -56,6 +56,7 @@ struct ScheduleListView: View { } } label: { 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: { HStack(spacing: 8) { Image(systemName: sportGroup.sport.iconName) + .accessibilityHidden(true) Text(sportGroup.sport.rawValue) } .font(.headline) @@ -219,6 +221,8 @@ struct SportFilterChip: View { .clipShape(Capsule()) } .buttonStyle(.plain) + .accessibilityValue(isSelected ? "Selected" : "Not selected") + .accessibilityAddTraits(isSelected ? .isSelected : []) } } @@ -297,6 +301,7 @@ struct GameRowView: View { Image(systemName: "mappin.circle.fill") .font(.caption2) .foregroundStyle(.tertiary) + .accessibilityHidden(true) Text(game.stadium.city) .font(.caption) .foregroundStyle(.secondary) @@ -320,6 +325,7 @@ struct TeamBadge: View { Circle() .fill(Color(hex: colorHex)) .frame(width: 8, height: 8) + .accessibilityHidden(true) } Text(team.abbreviation) diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index 64ce4fe..2f4997f 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -96,7 +96,7 @@ struct SettingsView: View { Section { ForEach(AppearanceMode.allCases) { mode in Button { - withAnimation(.easeInOut(duration: 0.2)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { AppearanceManager.shared.currentMode = mode AnalyticsManager.shared.track(.appearanceChanged(mode: mode.displayName)) } @@ -109,8 +109,9 @@ struct SettingsView: View { .frame(width: 32, height: 32) Image(systemName: mode.iconName) - .font(.system(size: 16)) + .font(.body) .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } VStack(alignment: .leading, spacing: 2) { @@ -128,11 +129,13 @@ struct SettingsView: View { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.warmOrange) .font(.title3) + .accessibilityHidden(true) } } .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityAddTraits(AppearanceManager.shared.currentMode == mode ? .isSelected : []) } } header: { Text("Appearance") @@ -148,7 +151,7 @@ struct SettingsView: View { Section { ForEach(AppTheme.allCases) { theme in Button { - withAnimation(.easeInOut(duration: 0.2)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { viewModel.selectedTheme = theme } } label: { @@ -181,11 +184,13 @@ struct SettingsView: View { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.warmOrange) .font(.title3) + .accessibilityHidden(true) } } .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityAddTraits(viewModel.selectedTheme == theme ? .isSelected : []) } } header: { Text("Theme") @@ -218,6 +223,7 @@ struct SettingsView: View { } icon: { Image(systemName: "sparkles") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } } } header: { @@ -306,6 +312,7 @@ struct SettingsView: View { } icon: { Image(systemName: "chart.bar.xaxis") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } } } header: { @@ -328,15 +335,30 @@ struct SettingsView: View { } 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")!) { - Label("EULA", systemImage: "doc.text") + Label { + Text("EULA") + } icon: { + Image(systemName: "doc.text") + .accessibilityHidden(true) + } } Link(destination: URL(string: "mailto:support@88oakapps.com")!) { - Label("Contact Support", systemImage: "envelope") + Label { + Text("Contact Support") + } icon: { + Image(systemName: "envelope") + .accessibilityHidden(true) + } } } header: { Text("About") @@ -365,6 +387,7 @@ struct SettingsView: View { } icon: { Image(systemName: "photo.badge.plus") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } } } header: { @@ -380,7 +403,12 @@ struct SettingsView: View { Button(role: .destructive) { showResetConfirmation = true } label: { - Label("Reset to Defaults", systemImage: "arrow.counterclockwise") + Label { + Text("Reset to Defaults") + } icon: { + Image(systemName: "arrow.counterclockwise") + .accessibilityHidden(true) + } } } .listRowBackground(Theme.cardBackground(colorScheme)) @@ -682,6 +710,7 @@ struct SettingsView: View { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) + .accessibilityHidden(true) } Button { @@ -714,6 +743,7 @@ struct SettingsView: View { Image(systemName: "chevron.right") .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } } .buttonStyle(.plain) @@ -741,6 +771,7 @@ struct SettingsView: View { guard !isSyncActionInProgress else { return } isSyncActionInProgress = true + AccessibilityAnnouncer.announce("Manual sync started.") Task { defer { isSyncActionInProgress = false } @@ -748,8 +779,10 @@ struct SettingsView: View { do { let result = try await BackgroundSyncManager.shared.triggerManualSync() syncActionMessage = "Sync complete. Updated \(result.totalUpdated) records." + AccessibilityAnnouncer.announce(syncActionMessage ?? "") } catch { syncActionMessage = "Sync failed: \(error.localizedDescription)" + AccessibilityAnnouncer.announce(syncActionMessage ?? "") } } } @@ -779,13 +812,13 @@ struct SyncStatusRow: View { // Status indicator Image(systemName: statusIcon) .foregroundStyle(statusColor) - .font(.system(size: 14)) + .font(.subheadline) .frame(width: 20) // Entity icon and name Image(systemName: status.entityType.iconName) .foregroundStyle(.secondary) - .font(.system(size: 14)) + .font(.subheadline) .frame(width: 20) Text(status.entityType.rawValue) @@ -812,6 +845,7 @@ struct SyncStatusRow: View { .foregroundStyle(.blue) } .buttonStyle(.plain) + .accessibilityLabel("View sync details") } } diff --git a/SportsTime/Features/Trip/Views/AddItem/CategoryPicker.swift b/SportsTime/Features/Trip/Views/AddItem/CategoryPicker.swift index 87c0055..bbfaa2b 100644 --- a/SportsTime/Features/Trip/Views/AddItem/CategoryPicker.swift +++ b/SportsTime/Features/Trip/Views/AddItem/CategoryPicker.swift @@ -29,7 +29,7 @@ struct CategoryPicker: View { isSelected: selectedCategory == category, colorScheme: colorScheme ) { - withAnimation(Theme.Animation.spring) { + Theme.Animation.withMotion(Theme.Animation.spring) { selectedCategory = category } } @@ -131,7 +131,7 @@ private struct CategoryPillButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .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) } } diff --git a/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift b/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift index c09a82c..c2860dc 100644 --- a/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift @@ -64,6 +64,7 @@ struct PlaceSearchSheet: View { HStack(spacing: Theme.Spacing.sm) { Image(systemName: "magnifyingglass") .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) TextField(searchPlaceholder, text: $searchQuery) .textFieldStyle(.plain) @@ -82,6 +83,7 @@ struct PlaceSearchSheet: View { Image(systemName: "xmark.circle.fill") .foregroundStyle(Theme.textMuted(colorScheme)) } + .minimumHitTarget() .accessibilityLabel("Clear search") } } @@ -148,6 +150,7 @@ struct PlaceSearchSheet: View { Image(systemName: "mappin.slash") .font(.largeTitle) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) Text("No places found") .font(.headline) @@ -180,6 +183,7 @@ struct PlaceSearchSheet: View { Image(systemName: "exclamationmark.triangle") .font(.largeTitle) .foregroundStyle(.orange) + .accessibilityHidden(true) Text("Search unavailable") .font(.headline) diff --git a/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift b/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift index a72de79..e9a6f29 100644 --- a/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift @@ -87,7 +87,7 @@ struct QuickAddItemSheet: View { } .sheet(isPresented: $showLocationSearch) { PlaceSearchSheet(category: selectedCategory) { place in - withAnimation(Theme.Animation.spring) { + Theme.Animation.withMotion(Theme.Animation.spring) { selectedPlace = place } // Use place name as title if empty @@ -209,6 +209,7 @@ struct QuickAddItemSheet: View { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } .padding(Theme.Spacing.md) .background(inputBackground) @@ -255,7 +256,7 @@ struct QuickAddItemSheet: View { // Remove button Button { - withAnimation(Theme.Animation.spring) { + Theme.Animation.withMotion(Theme.Animation.spring) { selectedPlace = nil } } label: { @@ -263,7 +264,9 @@ struct QuickAddItemSheet: View { .font(.title3) .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) .background(Theme.warmOrange.opacity(0.08)) @@ -272,9 +275,6 @@ struct QuickAddItemSheet: View { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .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 @@ -284,6 +284,7 @@ struct QuickAddItemSheet: View { Image(systemName: icon) .font(.caption) .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text(title) .font(.subheadline) @@ -440,7 +441,10 @@ private struct PressableStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .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 + ) } } diff --git a/SportsTime/Features/Trip/Views/AddItemSheet.swift b/SportsTime/Features/Trip/Views/AddItemSheet.swift index e294dec..ea30bd0 100644 --- a/SportsTime/Features/Trip/Views/AddItemSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItemSheet.swift @@ -138,6 +138,7 @@ struct AddItemSheet: View { HStack { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) + .accessibilityHidden(true) TextField("Search for a place...", text: $searchQuery) .textFieldStyle(.plain) .autocorrectionDisabled() @@ -156,6 +157,8 @@ struct AddItemSheet: View { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } + .minimumHitTarget() + .accessibilityLabel("Clear search") } } .padding(10) @@ -373,6 +376,7 @@ private struct PlaceResultRow: View { if isSelected { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) + .accessibilityHidden(true) } } .padding(.vertical, 10) @@ -380,6 +384,8 @@ private struct PlaceResultRow: View { .background(isSelected ? Color.green.opacity(0.1) : Color.clear) } .buttonStyle(.plain) + .accessibilityValue(isSelected ? "Selected" : "Not selected") + .accessibilityAddTraits(isSelected ? .isSelected : []) } private var formattedAddress: String? { @@ -422,6 +428,8 @@ private struct CategoryButton: View { ) } .buttonStyle(.plain) + .accessibilityValue(isSelected ? "Selected" : "Not selected") + .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/SportsTime/Features/Trip/Views/ItineraryRows/CustomItemRow.swift b/SportsTime/Features/Trip/Views/ItineraryRows/CustomItemRow.swift index 53b64a6..cf251b4 100644 --- a/SportsTime/Features/Trip/Views/ItineraryRows/CustomItemRow.swift +++ b/SportsTime/Features/Trip/Views/ItineraryRows/CustomItemRow.swift @@ -25,11 +25,13 @@ struct CustomItemRow: View { Image(systemName: "line.3.horizontal") .font(.title3) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityLabel("Drag to reorder") // Icon and Title if let info = customInfo { Text(info.icon) .font(.title3) + .accessibilityHidden(true) Text(info.title) .font(.body) @@ -47,6 +49,7 @@ struct CustomItemRow: View { } .padding(.vertical, Theme.Spacing.sm) .padding(.horizontal, Theme.Spacing.md) + .accessibilityElement(children: .combine) } .buttonStyle(.plain) } diff --git a/SportsTime/Features/Trip/Views/ItineraryRows/DayHeaderRow.swift b/SportsTime/Features/Trip/Views/ItineraryRows/DayHeaderRow.swift index 4a01965..990e356 100644 --- a/SportsTime/Features/Trip/Views/ItineraryRows/DayHeaderRow.swift +++ b/SportsTime/Features/Trip/Views/ItineraryRows/DayHeaderRow.swift @@ -34,6 +34,8 @@ struct DayHeaderRow: View { .font(.title2) .foregroundStyle(Theme.warmOrange) } + .minimumHitTarget() + .accessibilityLabel("Add item to this day") } if isEmpty { diff --git a/SportsTime/Features/Trip/Views/ItineraryRows/GameItemRow.swift b/SportsTime/Features/Trip/Views/ItineraryRows/GameItemRow.swift index c36bc31..d1de375 100644 --- a/SportsTime/Features/Trip/Views/ItineraryRows/GameItemRow.swift +++ b/SportsTime/Features/Trip/Views/ItineraryRows/GameItemRow.swift @@ -24,6 +24,7 @@ struct GameItemRow: View { HStack(spacing: 3) { Image(systemName: game.game.sport.iconName) .font(.caption2) + .accessibilityHidden(true) Text(game.game.sport.rawValue) .font(.caption2) } @@ -44,6 +45,7 @@ struct GameItemRow: View { HStack(spacing: 4) { Image(systemName: "building.2") .font(.caption2) + .accessibilityHidden(true) Text(game.stadium.name) .font(.subheadline) } @@ -57,6 +59,7 @@ struct GameItemRow: View { .font(.subheadline) .foregroundStyle(Theme.warmOrange) } + .accessibilityElement(children: .combine) .padding(Theme.Spacing.md) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) diff --git a/SportsTime/Features/Trip/Views/ItineraryRows/TravelItemRow.swift b/SportsTime/Features/Trip/Views/ItineraryRows/TravelItemRow.swift index 6d4e389..f66f143 100644 --- a/SportsTime/Features/Trip/Views/ItineraryRows/TravelItemRow.swift +++ b/SportsTime/Features/Trip/Views/ItineraryRows/TravelItemRow.swift @@ -24,6 +24,7 @@ struct TravelItemRow: View { Image(systemName: "line.3.horizontal") .font(.title3) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityLabel("Drag to reorder") // Car icon ZStack { @@ -35,12 +36,14 @@ struct TravelItemRow: View { .font(.body) .foregroundStyle(Theme.routeGold) } + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { if let info = travelInfo { Text("\(info.fromCity) \u{2192} \(info.toCity)") .font(.body) .foregroundStyle(Theme.textPrimary(colorScheme)) + .accessibilityLabel("\(info.fromCity) to \(info.toCity)") HStack(spacing: Theme.Spacing.xs) { if !info.formattedDistance.isEmpty { @@ -49,6 +52,7 @@ struct TravelItemRow: View { } if !info.formattedDistance.isEmpty && !info.formattedDuration.isEmpty { Text("\u{2022}") + .accessibilityHidden(true) } if !info.formattedDuration.isEmpty { Text(info.formattedDuration) @@ -61,6 +65,7 @@ struct TravelItemRow: View { Spacer() } + .accessibilityElement(children: .combine) .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift index 1a1c67f..c3500b4 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift @@ -1145,6 +1145,7 @@ struct GameRowCompact: View { HStack(spacing: 6) { Image(systemName: "building.2") .font(.caption) + .accessibilityHidden(true) Text(richGame.stadium.name) .font(.subheadline) } @@ -1175,6 +1176,7 @@ struct GameRowCompact: View { } .buttonStyle(.plain) .accessibilityLabel("Open \(richGame.stadium.name) in Maps") + .accessibilityHint("Opens this stadium location in Apple Maps") } .padding(Theme.Spacing.md) .background(Theme.cardBackgroundElevated(colorScheme)) @@ -1255,6 +1257,7 @@ struct TravelRowView: View { } .buttonStyle(.plain) .accessibilityLabel("Get directions from \(segment.fromLocation.name) to \(segment.toLocation.name)") + .accessibilityHint("Opens this stadium location in Apple Maps") } } .padding(Theme.Spacing.md) @@ -1304,6 +1307,7 @@ struct CustomItemRowView: View { Image(systemName: "mappin.circle.fill") .font(.caption) .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } } @@ -1336,12 +1340,14 @@ struct CustomItemRowView: View { } .buttonStyle(.plain) .accessibilityLabel("Open \(info.title) in Maps") + .accessibilityHint("Opens this stadium location in Apple Maps") } // Chevron indicates this is tappable Image(systemName: "chevron.right") .foregroundStyle(.tertiary) .font(.caption) + .accessibilityHidden(true) } .padding(.horizontal, Theme.Spacing.md) .padding(.vertical, Theme.Spacing.sm) diff --git a/SportsTime/Features/Trip/Views/RegionMapSelector.swift b/SportsTime/Features/Trip/Views/RegionMapSelector.swift index d68e754..cd09ff1 100644 --- a/SportsTime/Features/Trip/Views/RegionMapSelector.swift +++ b/SportsTime/Features/Trip/Views/RegionMapSelector.swift @@ -66,12 +66,36 @@ struct RegionMapSelector: View { HStack(spacing: 0) { Button { onToggle(.west) } label: { Color.clear } .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) Button { onToggle(.central) } label: { Color.clear } .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) Button { onToggle(.east) } label: { Color.clear } .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) } } @@ -166,6 +190,7 @@ struct RegionMapSelector: View { Circle() .stroke(Color.white.opacity(0.5), lineWidth: isSelected ? 2 : 0) ) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 1) { Text(region.shortName) diff --git a/SportsTime/Features/Trip/Views/TeamPickerView.swift b/SportsTime/Features/Trip/Views/TeamPickerView.swift index 0fd5191..fc82ad3 100644 --- a/SportsTime/Features/Trip/Views/TeamPickerView.swift +++ b/SportsTime/Features/Trip/Views/TeamPickerView.swift @@ -44,6 +44,7 @@ struct TeamPickerView: View { HStack { Image(systemName: "magnifyingglass") .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) TextField("Search teams...", text: $searchText) .textFieldStyle(.plain) @@ -55,6 +56,8 @@ struct TeamPickerView: View { Image(systemName: "xmark.circle.fill") .foregroundStyle(Theme.textMuted(colorScheme)) } + .minimumHitTarget() + .accessibilityLabel("Clear search") } } .padding(Theme.Spacing.sm) @@ -76,7 +79,7 @@ struct TeamPickerView: View { Spacer() Button("Clear all") { - withAnimation(Theme.Animation.spring) { + Theme.Animation.withMotion(Theme.Animation.spring) { selectedTeamIds.removeAll() } } @@ -102,7 +105,7 @@ struct TeamPickerView: View { } private func toggleTeam(_ team: Team) { - withAnimation(Theme.Animation.spring) { + Theme.Animation.withMotion(Theme.Animation.spring) { if selectedTeamIds.contains(team.id) { selectedTeamIds.remove(team.id) } else { @@ -139,6 +142,7 @@ private struct TeamCard: View { .font(.title3) .fontWeight(.bold) .foregroundStyle(.white) + .accessibilityHidden(true) } } @@ -167,6 +171,8 @@ private struct TeamCard: View { ) } .buttonStyle(.plain) + .accessibilityValue(isSelected ? "Selected" : "Not selected") + .accessibilityAddTraits(isSelected ? .isSelected : []) } private var teamColor: Color { diff --git a/SportsTime/Features/Trip/Views/TimelineItemView.swift b/SportsTime/Features/Trip/Views/TimelineItemView.swift index 3df3368..a9297ea 100644 --- a/SportsTime/Features/Trip/Views/TimelineItemView.swift +++ b/SportsTime/Features/Trip/Views/TimelineItemView.swift @@ -74,31 +74,34 @@ struct TimelineItemView: View { @ViewBuilder private var itemIcon: some View { - switch item { - case .stop(let stop): - if stop.hasGames { - Image(systemName: "sportscourt.fill") + Group { + switch item { + case .stop(let stop): + 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) - .frame(width: 32, height: 32) - .background(Circle().fill(.blue)) - } else { - Image(systemName: "mappin.circle.fill") - .foregroundStyle(.orange) - .font(.title2) + .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)) } - - 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 @@ -178,30 +181,34 @@ struct TravelItemContent: View { .font(.subheadline) .fontWeight(.medium) - Text("•") + Text("\u{2022}") .foregroundStyle(.secondary) + .accessibilityHidden(true) Text(segment.formattedDistance) .font(.subheadline) .foregroundStyle(.secondary) - Text("•") + Text("\u{2022}") .foregroundStyle(.secondary) + .accessibilityHidden(true) Text(segment.formattedDuration) .font(.subheadline) .foregroundStyle(.secondary) } - Text("\(segment.fromLocation.name) → \(segment.toLocation.name)") + Text("\(segment.fromLocation.name) \u{2192} \(segment.toLocation.name)") .font(.caption) .foregroundStyle(.secondary) + .accessibilityLabel("\(segment.fromLocation.name) to \(segment.toLocation.name)") // EV Charging stops if applicable if !segment.evChargingStops.isEmpty { HStack(spacing: 4) { Image(systemName: "bolt.fill") .foregroundStyle(.green) + .accessibilityHidden(true) Text("\(segment.evChargingStops.count) charging stop(s)") .font(.caption) .foregroundStyle(.secondary) @@ -263,6 +270,7 @@ struct TimelineGameRow: View { Image(systemName: richGame.game.sport.iconName) .foregroundStyle(richGame.game.sport.color) .frame(width: 20) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { // Matchup @@ -273,7 +281,8 @@ struct TimelineGameRow: View { // Time and venue (stadium local time) HStack(spacing: 4) { Text(richGame.localGameTimeShort) - Text("•") + Text("\u{2022}") + .accessibilityHidden(true) Text(richGame.stadium.name) } .font(.caption) @@ -282,6 +291,7 @@ struct TimelineGameRow: View { Spacer() } + .accessibilityElement(children: .combine) .padding(.vertical, 4) } } diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 499ff15..5e942d2 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -169,6 +169,7 @@ struct TripDetailView: View { } .foregroundStyle(Theme.warmOrange) } + .accessibilityLabel("Export trip as PDF") } } @@ -304,7 +305,10 @@ struct TripDetailView: View { .stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round)) .frame(width: 80, height: 80) .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") .font(.title2) @@ -363,6 +367,7 @@ struct TripDetailView: View { .shadow(color: .black.opacity(0.2), radius: 4, y: 2) } .accessibilityIdentifier("tripDetail.favoriteButton") + .accessibilityLabel(isSaved ? "Remove from favorites" : "Save to favorites") .padding(.top, 12) .padding(.trailing, 12) } @@ -556,7 +561,7 @@ struct TripDetailView: View { set: { targeted in // Only show as target if it's a valid drop location let shouldShowTarget = targeted && (draggedTravelId == nil || isValidTravelTarget) - withAnimation(.easeInOut(duration: 0.2)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { if shouldShowTarget { dropTargetId = sectionId } else if dropTargetId == sectionId { @@ -585,13 +590,13 @@ struct TripDetailView: View { .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding( get: { dropTargetId == sectionId }, set: { targeted in - // Only accept custom items on travel, not other travel - let shouldShow = targeted && draggedItem != nil - withAnimation(.easeInOut(duration: 0.2)) { - if shouldShow { - dropTargetId = sectionId - } else if dropTargetId == sectionId { - dropTargetId = nil + // Only accept custom items on travel, not other travel + let shouldShow = targeted && draggedItem != nil + Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { + if shouldShow { + dropTargetId = sectionId + } else if dropTargetId == sectionId { + dropTargetId = nil } } } @@ -628,7 +633,7 @@ struct TripDetailView: View { set: { targeted in // Only accept custom items, not travel let shouldShow = targeted && draggedItem != nil && draggedItem?.id != item.id - withAnimation(.easeInOut(duration: 0.2)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { if shouldShow { dropTargetId = sectionId } else if dropTargetId == sectionId { @@ -654,7 +659,7 @@ struct TripDetailView: View { set: { targeted in // Only accept custom items, not travel let shouldShow = targeted && draggedItem != nil - withAnimation(.easeInOut(duration: 0.2)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { if shouldShow { dropTargetId = sectionId } else if dropTargetId == sectionId { @@ -1323,7 +1328,7 @@ struct TripDetailView: View { do { try modelContext.save() - withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + Theme.Animation.withMotion(.spring(response: 0.3, dampingFraction: 0.6)) { isSaved = true } AnalyticsManager.shared.track(.tripSaved( @@ -1348,7 +1353,7 @@ struct TripDetailView: View { modelContext.delete(savedTrip) } try modelContext.save() - withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + Theme.Animation.withMotion(.spring(response: 0.3, dampingFraction: 0.6)) { isSaved = false } AnalyticsManager.shared.track(.tripDeleted(tripId: tripId.uuidString)) @@ -1818,7 +1823,7 @@ struct TravelSection: View { .background(Theme.routeGold.opacity(0.2)) Button { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + Theme.Animation.withMotion(.spring(response: 0.3, dampingFraction: 0.8)) { showEVChargers.toggle() } } label: { @@ -1836,6 +1841,7 @@ struct TravelSection: View { Image(systemName: showEVChargers ? "chevron.up" : "chevron.down") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } .padding(.horizontal, Theme.Spacing.md) .padding(.vertical, Theme.Spacing.sm) diff --git a/SportsTime/Features/Trip/Views/TripOptionsView.swift b/SportsTime/Features/Trip/Views/TripOptionsView.swift index a4289f6..c8d695e 100644 --- a/SportsTime/Features/Trip/Views/TripOptionsView.swift +++ b/SportsTime/Features/Trip/Views/TripOptionsView.swift @@ -308,7 +308,7 @@ struct TripOptionsView: View { hasAppliedDemoSelection = true // Auto-select "Most Games" sort after a delay DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) { - withAnimation(.easeInOut(duration: 0.3)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.3)) { sortOption = DemoConfig.demoSortOption } } @@ -329,7 +329,7 @@ struct TripOptionsView: View { Menu { ForEach(TripSortOption.allCases) { option in Button { - withAnimation(.easeInOut(duration: 0.2)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { sortOption = option } } label: { @@ -345,6 +345,7 @@ struct TripOptionsView: View { .font(.subheadline) Image(systemName: "chevron.down") .font(.caption) + .accessibilityHidden(true) } .foregroundStyle(Theme.textPrimary(colorScheme)) .padding(.horizontal, 16) @@ -397,6 +398,7 @@ struct TripOptionsView: View { .contentTransition(.identity) Image(systemName: "chevron.down") .font(.caption2) + .accessibilityHidden(true) } .foregroundStyle(paceFilter == .all ? Theme.textPrimary(colorScheme) : Theme.warmOrange) .padding(.horizontal, 12) @@ -420,12 +422,12 @@ struct TripOptionsView: View { HStack(spacing: 8) { ForEach(CitiesFilter.allCases) { filter in Button { - withAnimation(.easeInOut(duration: 0.2)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { citiesFilter = filter } } label: { 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)) .padding(.horizontal, 12) .padding(.vertical, 6) @@ -446,15 +448,16 @@ struct TripOptionsView: View { private var emptyFilterState: some View { VStack(spacing: Theme.Spacing.md) { Image(systemName: "line.3.horizontal.decrease.circle") - .font(.system(size: 48)) + .font(.largeTitle) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) Text("No routes match your filters") .font(.body) .foregroundStyle(Theme.textSecondary(colorScheme)) Button { - withAnimation { + Theme.Animation.withMotion { citiesFilter = .noLimit paceFilter = .all } @@ -524,6 +527,7 @@ struct TripOptionCard: View { .font(.caption2) } .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text(uniqueCities.last ?? "") .font(.subheadline) @@ -560,7 +564,7 @@ struct TripOptionCard: View { // AI-generated description (after stats) if let description = aiDescription { Text(description) - .font(.system(size: 13, weight: .regular)) + .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) .fixedSize(horizontal: false, vertical: true) .transition(.opacity) @@ -578,8 +582,9 @@ struct TripOptionCard: View { // Right: Chevron Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) @@ -607,7 +612,7 @@ struct TripOptionCard: View { let input = RouteDescriptionInput(from: option, games: games) if let description = await RouteDescriptionGenerator.shared.generateDescription(for: input) { - withAnimation(.easeInOut(duration: 0.3)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.3)) { aiDescription = description } } diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/DateRangePicker.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/DateRangePicker.swift index 976aedc..c8c764e 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/DateRangePicker.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/DateRangePicker.swift @@ -25,6 +25,7 @@ struct DateRangePicker: View { private let calendar = Calendar.current private let daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"] + private let daysOfWeekFull = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] private var monthYearString: String { let formatter = DateFormatter() @@ -96,13 +97,13 @@ struct DateRangePicker: View { if isDemoMode && !hasAppliedDemoSelection { hasAppliedDemoSelection = true DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) { - withAnimation(.easeInOut(duration: 0.3)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.3)) { // Navigate to demo month displayedMonth = DemoConfig.demoStartDate } } DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.5) { - withAnimation(.easeInOut(duration: 0.3)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.3)) { startDate = DemoConfig.demoStartDate endDate = DemoConfig.demoEndDate selectionState = .complete @@ -119,7 +120,7 @@ struct DateRangePicker: View { let newYear = calendar.component(.year, from: newValue) if oldMonth != newMonth || oldYear != newYear { - withAnimation(.easeInOut(duration: 0.2)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { displayedMonth = calendar.startOfDay(for: newValue) } } @@ -148,6 +149,7 @@ struct DateRangePicker: View { Image(systemName: "arrow.right") .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) // End date VStack(alignment: .trailing, spacing: 4) { @@ -168,17 +170,18 @@ struct DateRangePicker: View { private var monthNavigation: some View { HStack { Button { - withAnimation(.easeInOut(duration: 0.2)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth } } label: { Image(systemName: "chevron.left") .font(.body) .foregroundStyle(Theme.warmOrange) - .frame(width: 36, height: 36) + .frame(minWidth: 44, minHeight: 44) .background(Theme.warmOrange.opacity(0.15)) .clipShape(Circle()) } + .accessibilityLabel("Previous month") .accessibilityIdentifier("wizard.dates.previousMonth") Spacer() @@ -191,28 +194,30 @@ struct DateRangePicker: View { Spacer() Button { - withAnimation(.easeInOut(duration: 0.2)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.2)) { displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth } } label: { Image(systemName: "chevron.right") .font(.body) .foregroundStyle(Theme.warmOrange) - .frame(width: 36, height: 36) + .frame(minWidth: 44, minHeight: 44) .background(Theme.warmOrange.opacity(0.15)) .clipShape(Circle()) } + .accessibilityLabel("Next month") .accessibilityIdentifier("wizard.dates.nextMonth") } } private var daysOfWeekHeader: some View { HStack(spacing: 0) { - ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { _, day in + ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { index, day in Text(day) .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) .frame(maxWidth: .infinity) + .accessibilityLabel(daysOfWeekFull[index]) } } } @@ -243,6 +248,7 @@ struct DateRangePicker: View { HStack(spacing: Theme.Spacing.xs) { Image(systemName: "calendar.badge.clock") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text("\(tripDuration) day\(tripDuration == 1 ? "" : "s")") .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) @@ -348,7 +354,7 @@ struct DayCell: View { } Text(dayNumber) - .font(.system(size: 14, weight: (isStart || isEnd) ? .bold : .medium)) + .font(.subheadline) .foregroundStyle( isPast ? Theme.textMuted(colorScheme).opacity(0.5) : (isStart || isEnd) ? .white : diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift index 882d12f..c3bd4a2 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift @@ -123,26 +123,53 @@ struct GamePickerStep: View { .fontWeight(.medium) .foregroundStyle(Theme.textSecondary(colorScheme)) - Button { - if isEnabled { onTap() } - } label: { - HStack { - Image(systemName: icon) - .foregroundStyle(isEnabled ? Theme.warmOrange : Theme.textMuted(colorScheme)) + if let value = value { + HStack(spacing: Theme.Spacing.sm) { + Button { + if isEnabled { onTap() } + } label: { + HStack { + Image(systemName: icon) + .foregroundStyle(isEnabled ? Theme.warmOrange : Theme.textMuted(colorScheme)) + .accessibilityHidden(true) - if let value = value { - Text(value) - .font(.subheadline) - .foregroundStyle(Theme.textPrimary(colorScheme)) - .lineLimit(1) + Text(value) + .font(.subheadline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .lineLimit(1) - Spacer() - - Button(action: onClear) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(Theme.textMuted(colorScheme)) + Spacer() } - } 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) .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) @@ -152,19 +179,20 @@ struct GamePickerStep: View { Image(systemName: "chevron.right") .font(.caption) .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) - .background(Theme.cardBackgroundElevated(colorScheme)) - .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) } - .buttonStyle(.plain) - .disabled(!isEnabled) } } @@ -177,6 +205,7 @@ struct GamePickerStep: View { HStack { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text("\(selectedGameIds.count) game\(selectedGameIds.count == 1 ? "" : "s") selected") .font(.subheadline) .fontWeight(.medium) @@ -201,6 +230,8 @@ struct GamePickerStep: View { Image(systemName: "xmark.circle.fill") .foregroundStyle(Theme.textMuted(colorScheme)) } + .minimumHitTarget() + .accessibilityLabel("Remove \(game.matchupDescription)") } .padding(Theme.Spacing.sm) .background(Theme.cardBackgroundElevated(colorScheme)) @@ -236,6 +267,7 @@ struct GamePickerStep: View { HStack { Image(systemName: "calendar") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text("Trip Date Range") .font(.subheadline) .fontWeight(.medium) @@ -353,15 +385,18 @@ private struct SportsPickerSheet: View { if selectedSports.contains(sport) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } else { Image(systemName: "circle") .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } } .padding(.vertical, Theme.Spacing.xs) .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityAddTraits(selectedSports.contains(sport) ? .isSelected : []) } } .listStyle(.plain) @@ -451,15 +486,18 @@ private struct TeamsPickerSheet: View { if selectedTeamIds.contains(team.id) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } else { Image(systemName: "circle") .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } } .padding(.vertical, Theme.Spacing.xs) .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityAddTraits(selectedTeamIds.contains(team.id) ? .isSelected : []) } } header: { HStack(spacing: Theme.Spacing.xs) { @@ -555,15 +593,19 @@ private struct GamesPickerSheet: View { if selectedGameIds.contains(game.id) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } else { Image(systemName: "circle") .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } } .padding(.vertical, Theme.Spacing.xs) .contentShape(Rectangle()) + .accessibilityElement(children: .combine) } .buttonStyle(.plain) + .accessibilityAddTraits(selectedGameIds.contains(game.id) ? .isSelected : []) } } header: { Text(date, style: .date) diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift index 2cbc3d2..d803395 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift @@ -48,6 +48,7 @@ struct LocationSearchSheet: View { HStack { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) + .accessibilityHidden(true) TextField("Search cities, addresses, places...", text: $searchText) .textFieldStyle(.plain) .autocorrectionDisabled() @@ -61,6 +62,8 @@ struct LocationSearchSheet: View { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } + .minimumHitTarget() + .accessibilityLabel("Clear search") } } .padding() @@ -85,6 +88,7 @@ struct LocationSearchSheet: View { Image(systemName: "mappin.circle.fill") .foregroundStyle(.red) .font(.title2) + .accessibilityHidden(true) VStack(alignment: .leading) { Text(result.name) .foregroundStyle(.primary) @@ -97,6 +101,7 @@ struct LocationSearchSheet: View { Spacer() Image(systemName: "plus.circle") .foregroundStyle(.blue) + .accessibilityHidden(true) } } .buttonStyle(.plain) diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/LocationsStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/LocationsStep.swift index 024bc09..57c568a 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/LocationsStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/LocationsStep.swift @@ -49,6 +49,7 @@ struct LocationsStep: View { HStack(spacing: Theme.Spacing.sm) { Image(systemName: "arrow.triangle.2.circlepath") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text("Round trip (return to start)") .font(.subheadline) .foregroundStyle(Theme.textPrimary(colorScheme)) @@ -107,6 +108,7 @@ struct LocationsStep: View { HStack { Image(systemName: "mappin.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(location.name) @@ -128,6 +130,8 @@ struct LocationsStep: View { Image(systemName: "xmark.circle.fill") .foregroundStyle(Theme.textMuted(colorScheme)) } + .minimumHitTarget() + .accessibilityLabel("Clear location") } .padding(Theme.Spacing.sm) .background(Theme.cardBackgroundElevated(colorScheme)) @@ -138,6 +142,7 @@ struct LocationsStep: View { HStack { Image(systemName: "plus.circle") .foregroundStyle(Theme.warmOrange) + .accessibilityLabel("Add location") Text(placeholder) .font(.subheadline) @@ -148,6 +153,7 @@ struct LocationsStep: View { Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) } .padding(Theme.Spacing.sm) .background(Theme.cardBackgroundElevated(colorScheme)) diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/MustStopsStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/MustStopsStep.swift index 1c0cab4..6183492 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/MustStopsStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/MustStopsStep.swift @@ -25,6 +25,7 @@ struct MustStopsStep: View { HStack { Image(systemName: "mappin.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text(location.name) .font(.subheadline) @@ -38,6 +39,8 @@ struct MustStopsStep: View { Image(systemName: "xmark.circle.fill") .foregroundStyle(Theme.textMuted(colorScheme)) } + .minimumHitTarget() + .accessibilityLabel("Remove location") } .padding(Theme.Spacing.sm) .background(Theme.cardBackgroundElevated(colorScheme)) @@ -56,6 +59,7 @@ struct MustStopsStep: View { .font(.subheadline) .foregroundStyle(Theme.warmOrange) } + .accessibilityLabel("Add must-see location") Text("Skip this step if you don't have specific cities in mind") .font(.caption) diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift index 0a20a7b..78c2946 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift @@ -39,7 +39,7 @@ struct PlanningModeStep: View { .onAppear { if isDemoMode && selection == nil { DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) { - withAnimation(.easeInOut(duration: 0.3)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.3)) { selection = DemoConfig.demoPlanningMode } } @@ -63,6 +63,7 @@ private struct WizardModeCard: View { .font(.title2) .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textSecondary(colorScheme)) .frame(width: 32) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(mode.displayName) @@ -79,6 +80,7 @@ private struct WizardModeCard: View { if isSelected { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } } .padding(Theme.Spacing.md) @@ -89,7 +91,11 @@ private struct WizardModeCard: View { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .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)") .buttonStyle(.plain) } diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift index 24fee62..0447abf 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift @@ -72,6 +72,7 @@ private struct OptionButton: View { Image(systemName: icon) .font(.title2) .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textSecondary(colorScheme)) + .accessibilityHidden(true) Text(title) .font(.caption) @@ -90,6 +91,8 @@ private struct OptionButton: View { ) } .buttonStyle(.plain) + .accessibilityValue(isSelected ? "Selected" : "Not selected") + .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift index 8dd1ee4..b6d0369 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift @@ -63,6 +63,7 @@ struct ReviewStep: View { HStack(spacing: Theme.Spacing.xs) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) + .accessibilityHidden(true) Text("Complete all required fields to continue") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) @@ -85,6 +86,7 @@ struct ReviewStep: View { .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) } .accessibilityIdentifier("wizard.planTripButton") + .accessibilityHint("Creates trip itinerary based on your selections") .disabled(!canPlanTrip || isPlanning) } .padding(Theme.Spacing.lg) @@ -155,6 +157,7 @@ private struct ReviewRow: View { Image(systemName: "exclamationmark.circle.fill") .font(.caption) .foregroundStyle(.red) + .accessibilityHidden(true) } } } diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift index 600b195..8dfaab6 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift @@ -63,6 +63,7 @@ private struct RoutePreferenceCard: View { .font(.title2) .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textSecondary(colorScheme)) .frame(width: 32) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(preference.displayName) @@ -79,6 +80,7 @@ private struct RoutePreferenceCard: View { if isSelected { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } } .padding(Theme.Spacing.md) @@ -89,7 +91,11 @@ private struct RoutePreferenceCard: View { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .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) } } diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift index 68568ea..8d61329 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift @@ -60,7 +60,7 @@ struct SportsStep: View { if isDemoMode && !hasAppliedDemoSelection && selectedSports.isEmpty { hasAppliedDemoSelection = true DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) { - withAnimation(.easeInOut(duration: 0.3)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.3)) { _ = selectedSports.insert(DemoConfig.demoSport) } } @@ -92,6 +92,7 @@ private struct SportCard: View { Image(systemName: sport.iconName) .font(.title2) .foregroundStyle(cardColor) + .accessibilityHidden(true) Text(sport.rawValue) .font(.caption) @@ -111,7 +112,15 @@ private struct SportCard: View { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .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())") .buttonStyle(.plain) .opacity(isAvailable ? 1.0 : 0.5) diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/TeamPickerStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/TeamPickerStep.swift index f0da7fd..1c1638c 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/TeamPickerStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/TeamPickerStep.swift @@ -28,41 +28,60 @@ struct TeamPickerStep: View { subtitle: "See their home and away games" ) - // Selection button - Button { - showTeamPicker = true - } label: { - HStack { - if let team = selectedTeam { - // Show selected team - Circle() - .fill(team.primaryColor.map { Color(hex: $0) } ?? team.sport.themeColor) - .frame(width: 24, height: 24) + if let team = selectedTeam { + HStack(spacing: Theme.Spacing.sm) { + Button { + showTeamPicker = true + } label: { + HStack { + Circle() + .fill(team.primaryColor.map { Color(hex: $0) } ?? team.sport.themeColor) + .frame(width: 24, height: 24) - VStack(alignment: .leading, spacing: 2) { - Text(team.fullName) - .font(.subheadline) - .fontWeight(.medium) - .foregroundStyle(Theme.textPrimary(colorScheme)) + VStack(alignment: .leading, spacing: 2) { + Text(team.fullName) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(Theme.textPrimary(colorScheme)) - Text(team.sport.rawValue) - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) + Text(team.sport.rawValue) + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + + Spacer() } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) - Spacer() - - Button { - selectedTeamId = nil - selectedSport = nil - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(Theme.textMuted(colorScheme)) - } - } else { - // Empty state + Button { + selectedTeamId = nil + selectedSport = nil + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .buttonStyle(.plain) + .minimumHitTarget() + .accessibilityLabel("Clear team 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) + ) + } else { + // Selection button + Button { + showTeamPicker = true + } label: { + HStack { Image(systemName: "person.2.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text("Select a team") .font(.subheadline) @@ -73,17 +92,18 @@ struct TeamPickerStep: View { Image(systemName: "chevron.right") .font(.caption) .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) - .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) } - .buttonStyle(.plain) } .padding(Theme.Spacing.lg) .background(Theme.cardBackground(colorScheme)) @@ -214,11 +234,14 @@ private struct TeamListView: View { if selectedTeamId == team.id { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } } .padding(.vertical, Theme.Spacing.xs) + .accessibilityElement(children: .combine) } .buttonStyle(.plain) + .accessibilityAddTraits(selectedTeamId == team.id ? .isSelected : []) } } .listStyle(.plain) diff --git a/SportsTime/Features/Trip/Views/Wizard/TeamFirstWizardStep.swift b/SportsTime/Features/Trip/Views/Wizard/TeamFirstWizardStep.swift index e31f896..954c8a5 100644 --- a/SportsTime/Features/Trip/Views/Wizard/TeamFirstWizardStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/TeamFirstWizardStep.swift @@ -33,28 +33,46 @@ struct TeamFirstWizardStep: View { subtitle: "Select 2 or more teams to find optimal trip windows" ) - // Selection button - Button { - showTeamPicker = true - } label: { - HStack { - if !selectedTeams.isEmpty { - // Show selected teams - teamPreview - - Spacer() - - Button { - selectedTeamIds.removeAll() - selectedSport = nil - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(Theme.textMuted(colorScheme)) + if !selectedTeams.isEmpty { + HStack(spacing: Theme.Spacing.sm) { + Button { + showTeamPicker = true + } label: { + HStack { + teamPreview + Spacer() } - } else { - // Empty state + .contentShape(Rectangle()) + } + .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") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) Text("Select teams") .font(.subheadline) @@ -65,17 +83,18 @@ struct TeamFirstWizardStep: View { Image(systemName: "chevron.right") .font(.caption) .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) - .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) } - .buttonStyle(.plain) // Validation message if selectedTeamIds.isEmpty { @@ -139,6 +158,7 @@ struct TeamFirstWizardStep: View { .zIndex(Double(4 - index)) } } + .accessibilityHidden(true) Text("\(selectedTeamIds.count) teams") .font(.subheadline) @@ -279,14 +299,17 @@ private struct TeamMultiSelectListView: View { if selectedTeamIds.contains(team.id) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.warmOrange) + .accessibilityHidden(true) } else { Image(systemName: "circle") .foregroundStyle(Theme.textMuted(colorScheme).opacity(0.5)) + .accessibilityHidden(true) } } .padding(.vertical, Theme.Spacing.xs) } .buttonStyle(.plain) + .accessibilityAddTraits(selectedTeamIds.contains(team.id) ? .isSelected : []) } } .listStyle(.plain) @@ -316,7 +339,7 @@ private struct TeamMultiSelectListView: View { } private func toggleTeam(_ team: Team) { - withAnimation(.easeInOut(duration: 0.15)) { + Theme.Animation.withMotion(.easeInOut(duration: 0.15)) { if selectedTeamIds.contains(team.id) { selectedTeamIds.remove(team.id) } else { diff --git a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift index 0087bd9..97301c0 100644 --- a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift +++ b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift @@ -133,7 +133,7 @@ struct TripWizardView: View { } .padding(Theme.Spacing.md) .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() diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index 9672693..d7e5665 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -238,16 +238,15 @@ struct BootstrappedContentView: View { private func performBackgroundSync(context: ModelContext) async { let log = SyncLogger.shared log.log("🔄 [SYNC] Starting background sync...") + AccessibilityAnnouncer.announce("Sync started.") // Log diagnostic info for debugging CloudKit container issues let bundleId = Bundle.main.bundleIdentifier ?? "unknown" - let container = CKContainer.default() + let container = CloudKitContainerConfig.makeContainer() let containerId = container.containerIdentifier ?? "unknown" log.log("🔧 [DIAG] Bundle ID: \(bundleId)") - log.log("🔧 [DIAG] CKContainer.default(): \(containerId)") - if let entitlementContainers = Bundle.main.object(forInfoDictionaryKey: "com.apple.developer.icloud-container-identifiers") as? [String] { - log.log("🔧 [DIAG] Entitlement containers: \(entitlementContainers.joined(separator: ", "))") - } + log.log("🔧 [DIAG] CloudKit container: \(containerId)") + log.log("🔧 [DIAG] Configured container: \(CloudKitContainerConfig.identifier)") if let accountStatus = try? await container.accountStatus() { log.log("🔧 [DIAG] iCloud account status: \(accountStatus.rawValue) (0=couldNotDetermine, 1=available, 2=restricted, 3=noAccount)") } else { @@ -294,10 +293,13 @@ struct BootstrappedContentView: View { } else { log.log("🔄 [SYNC] No updates - skipping DataProvider reload") } + AccessibilityAnnouncer.announce("Sync complete. Updated \(result.totalUpdated) records.") } catch CanonicalSyncService.SyncError.cloudKitUnavailable { log.log("❌ [SYNC] CloudKit unavailable - using local data only") + AccessibilityAnnouncer.announce("Cloud sync unavailable. Using local data.") } catch { log.log("❌ [SYNC] Error: \(error.localizedDescription)") + AccessibilityAnnouncer.announce("Sync failed. \(error.localizedDescription)") } } diff --git a/SportsTime/SportsTimeDebug.entitlements b/SportsTime/SportsTimeDebug.entitlements index e38d0f0..f2bb18f 100644 --- a/SportsTime/SportsTimeDebug.entitlements +++ b/SportsTime/SportsTimeDebug.entitlements @@ -6,7 +6,7 @@ development com.apple.developer.icloud-container-identifiers - iCloud.com.88oakapps.SportsTime.Debug + iCloud.com.88oakapps.SportsTime com.apple.developer.icloud-services diff --git a/SportsTimeTests/Features/Polls/PollVotingViewModelTests.swift b/SportsTimeTests/Features/Polls/PollVotingViewModelTests.swift new file mode 100644 index 0000000..fc9a038 --- /dev/null +++ b/SportsTimeTests/Features/Polls/PollVotingViewModelTests.swift @@ -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]) + } +} diff --git a/SportsTimeUITests/SportsTimeUITests.swift b/SportsTimeUITests/SportsTimeUITests.swift index 690b356..5ea277d 100644 --- a/SportsTimeUITests/SportsTimeUITests.swift +++ b/SportsTimeUITests/SportsTimeUITests.swift @@ -18,6 +18,28 @@ final class SportsTimeUITests: XCTestCase { // 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) /// Complete trip planning demo with continuous smooth scrolling.