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:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
14
SportsTime/Core/Services/CloudKitContainerConfig.swift
Normal file
14
SportsTime/Core/Services/CloudKitContainerConfig.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -27,6 +27,8 @@ struct ProGateModifier: ViewModifier {
|
||||
.onTapGesture {
|
||||
showPaywall = true
|
||||
}
|
||||
.accessibilityLabel("Pro feature locked")
|
||||
.accessibilityHint("Double-tap to view upgrade options")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPaywall) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,6 +15,7 @@ struct ProBadge: View {
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Theme.warmOrange, in: Capsule())
|
||||
.accessibilityLabel("Pro feature")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ struct PollsListView: View {
|
||||
showJoinPoll = true
|
||||
} label: {
|
||||
Image(systemName: "link.badge.plus")
|
||||
.accessibilityLabel("Join a poll")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 : [])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ struct DayHeaderRow: View {
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
.minimumHitTarget()
|
||||
.accessibilityLabel("Add item to this day")
|
||||
}
|
||||
|
||||
if isEmpty {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 :
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 : [])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user