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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user