feat: add WCAG AA accessibility app-wide, fix CloudKit container config, remove debug logs

- Add VoiceOver labels, hints, and element grouping across all 60+ views
- Add Reduce Motion support (Theme.Animation.prefersReducedMotion) to all animations
- Replace fixed font sizes with semantic Dynamic Type styles
- Hide decorative elements from VoiceOver with .accessibilityHidden(true)
- Add .minimumHitTarget() modifier ensuring 44pt touch targets
- Add AccessibilityAnnouncer utility for VoiceOver announcements
- Improve color contrast values in Theme.swift for WCAG AA compliance
- Extract CloudKitContainerConfig for explicit container identity
- Remove PostHog debug console log from AnalyticsManager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-11 09:27:23 -06:00
parent e9c15d70b1
commit d63d311cab
77 changed files with 982 additions and 263 deletions

View File

@@ -46,7 +46,11 @@
"WebFetch(domain:swiftpackageindex.com)",
"WebFetch(domain: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)"
]
}
}

View File

@@ -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;

View File

@@ -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)
}

View File

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

View File

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

View File

@@ -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"

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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 }
}
)
}

View File

@@ -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() }
}
}
}
}

View File

@@ -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 {

View File

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

View File

@@ -81,6 +81,7 @@ struct SharePreviewView<Content: ShareableContent>: 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<Content: ShareableContent>: 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<Content: ShareableContent>: View {
} label: {
HStack {
Image(systemName: "camera.fill")
.accessibilityHidden(true)
Text("Share to Instagram")
}
.frame(maxWidth: .infinity)
@@ -175,6 +180,7 @@ struct SharePreviewView<Content: ShareableContent>: View {
} label: {
HStack {
Image(systemName: "doc.on.doc")
.accessibilityHidden(true)
Text("Copy to Clipboard")
}
.frame(maxWidth: .infinity)
@@ -195,6 +201,7 @@ struct SharePreviewView<Content: ShareableContent>: View {
HStack {
Image(systemName: "checkmark.circle.fill")
.accessibilityHidden(true)
Text("Copied to clipboard")
}
.padding()

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)

View File

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

View File

@@ -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")
}
}

View File

@@ -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)

View File

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

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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)
}

View File

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

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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")

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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")

View File

@@ -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)
}

View File

@@ -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))

View File

@@ -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)

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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
)
}
}

View File

@@ -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 : [])
}
}

View File

@@ -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)
}

View File

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

View File

@@ -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))

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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 :

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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 : [])
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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)")
}
}

View File

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

View File

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

View File

@@ -18,6 +18,28 @@ final class SportsTimeUITests: XCTestCase {
// Put teardown code here.
}
// 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.