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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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