Add 22 new UI tests across 8 test files covering Home, Schedule, Progress, Settings, TabNavigation, TripSaving, and TripOptions. Add accessibility identifiers to 11 view files for test element discovery. Fix sport chip assertion logic (all sports start selected, tap deselects), scroll container issues on iOS 26 nested ScrollViews, toggle interaction, and delete trip flow. Update QA coverage map from 32 to 54 automated test cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
232 lines
7.9 KiB
Swift
232 lines
7.9 KiB
Swift
//
|
|
// ReviewStep.swift
|
|
// SportsTime
|
|
//
|
|
// Step 8 of the trip wizard - review and submit.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ReviewStep: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
let planningMode: PlanningMode
|
|
let selectedSports: Set<Sport>
|
|
let startDate: Date
|
|
let endDate: Date
|
|
let selectedRegions: Set<Region>
|
|
let routePreference: RoutePreference
|
|
let allowRepeatCities: Bool
|
|
let mustStopLocations: [LocationInput]
|
|
let isPlanning: Bool
|
|
let canPlanTrip: Bool
|
|
let fieldValidation: FieldValidation
|
|
let onPlan: () -> Void
|
|
|
|
// Mode-specific display values (passed from parent)
|
|
var selectedGameCount: Int = 0
|
|
var selectedTeamName: String? = nil
|
|
var startLocationName: String? = nil
|
|
var endLocationName: String? = nil
|
|
var teamFirstTeamCount: Int = 0
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
|
StepHeader(
|
|
title: "Ready to plan your trip!",
|
|
subtitle: "Review your selections"
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
|
// Mode (always shown)
|
|
ReviewRow(label: "Mode", value: planningMode.displayName)
|
|
|
|
// Mode-specific required fields
|
|
ForEach(fieldValidation.requiredFields, id: \.name) { field in
|
|
ReviewRow(
|
|
label: field.name,
|
|
value: displayValue(for: field.name),
|
|
isMissing: field.status == .missing
|
|
)
|
|
}
|
|
|
|
// Optional: Must-stops (shown if any selected)
|
|
if !mustStopLocations.isEmpty {
|
|
ReviewRow(label: "Must-stops", value: mustStopLocations.map(\.name).joined(separator: ", "))
|
|
}
|
|
}
|
|
.padding(Theme.Spacing.sm)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
|
|
// Missing fields warning
|
|
if !fieldValidation.missingFields.isEmpty {
|
|
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))
|
|
}
|
|
.accessibilityIdentifier("wizard.missingFieldsWarning")
|
|
}
|
|
|
|
Button(action: onPlan) {
|
|
HStack(spacing: Theme.Spacing.sm) {
|
|
if isPlanning {
|
|
LoadingSpinner(size: .small)
|
|
.colorScheme(.dark) // Force white on orange button
|
|
}
|
|
Text(isPlanning ? "Planning..." : "Plan My Trip")
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(Theme.Spacing.md)
|
|
.background(canPlanTrip ? Theme.warmOrange : Theme.textMuted(colorScheme))
|
|
.foregroundStyle(.white)
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
|
}
|
|
.accessibilityIdentifier("wizard.planTripButton")
|
|
.accessibilityHint("Creates trip itinerary based on your selections")
|
|
.disabled(!canPlanTrip || isPlanning)
|
|
}
|
|
.padding(Theme.Spacing.lg)
|
|
.background(Theme.cardBackground(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
|
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
|
}
|
|
}
|
|
|
|
private func displayValue(for fieldName: String) -> String {
|
|
switch fieldName {
|
|
case "Sports":
|
|
return selectedSports.isEmpty ? "Not selected" : selectedSports.map(\.rawValue).sorted().joined(separator: ", ")
|
|
case "Dates":
|
|
return dateRangeText
|
|
case "Regions":
|
|
return selectedRegions.isEmpty ? "Not selected" : selectedRegions.map(\.shortName).sorted().joined(separator: ", ")
|
|
case "Route Preference":
|
|
return routePreference.displayName
|
|
case "Repeat Cities":
|
|
return allowRepeatCities ? "Yes" : "No"
|
|
case "Games":
|
|
return selectedGameCount > 0 ? "\(selectedGameCount) game\(selectedGameCount == 1 ? "" : "s") selected" : "Not selected"
|
|
case "Team":
|
|
return selectedTeamName ?? "Not selected"
|
|
case "Start Location":
|
|
return startLocationName ?? "Not selected"
|
|
case "End Location":
|
|
return endLocationName ?? "Not selected"
|
|
case "Teams":
|
|
return teamFirstTeamCount >= 2 ? "\(teamFirstTeamCount) teams selected" : "Select at least 2 teams"
|
|
default:
|
|
return "—"
|
|
}
|
|
}
|
|
|
|
private var dateRangeText: String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .medium
|
|
return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))"
|
|
}
|
|
}
|
|
|
|
// MARK: - Review Row
|
|
|
|
private struct ReviewRow: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
let label: String
|
|
let value: String
|
|
var isMissing: Bool = false
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top) {
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(isMissing ? .red : Theme.textMuted(colorScheme))
|
|
.frame(width: 80, alignment: .leading)
|
|
|
|
Text(value)
|
|
.font(.subheadline)
|
|
.foregroundStyle(isMissing ? .red : Theme.textPrimary(colorScheme))
|
|
|
|
Spacer()
|
|
|
|
if isMissing {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview("All Valid") {
|
|
ReviewStep(
|
|
planningMode: .dateRange,
|
|
selectedSports: [.mlb, .nba],
|
|
startDate: Date(),
|
|
endDate: Date().addingTimeInterval(86400 * 7),
|
|
selectedRegions: [.east, .central],
|
|
routePreference: .balanced,
|
|
allowRepeatCities: false,
|
|
mustStopLocations: [],
|
|
isPlanning: false,
|
|
canPlanTrip: true,
|
|
fieldValidation: FieldValidation(
|
|
planningMode: .dateRange,
|
|
sports: .valid,
|
|
dates: .valid,
|
|
regions: .valid,
|
|
routePreference: .valid,
|
|
repeatCities: .valid,
|
|
selectedGames: .valid,
|
|
selectedTeam: .valid,
|
|
startLocation: .valid,
|
|
endLocation: .valid,
|
|
teamFirstTeams: .valid,
|
|
teamFirstTeamCount: 0
|
|
),
|
|
onPlan: {}
|
|
)
|
|
.padding()
|
|
.themedBackground()
|
|
}
|
|
|
|
#Preview("Missing Fields") {
|
|
ReviewStep(
|
|
planningMode: .dateRange,
|
|
selectedSports: [],
|
|
startDate: Date(),
|
|
endDate: Date().addingTimeInterval(86400 * 7),
|
|
selectedRegions: [],
|
|
routePreference: .balanced,
|
|
allowRepeatCities: false,
|
|
mustStopLocations: [],
|
|
isPlanning: false,
|
|
canPlanTrip: false,
|
|
fieldValidation: FieldValidation(
|
|
planningMode: .dateRange,
|
|
sports: .missing,
|
|
dates: .valid,
|
|
regions: .missing,
|
|
routePreference: .valid,
|
|
repeatCities: .missing,
|
|
selectedGames: .valid,
|
|
selectedTeam: .valid,
|
|
startLocation: .valid,
|
|
endLocation: .valid,
|
|
teamFirstTeams: .valid,
|
|
teamFirstTeamCount: 0
|
|
),
|
|
onPlan: {}
|
|
)
|
|
.padding()
|
|
.themedBackground()
|
|
}
|