diff --git a/SportsTime/Core/Services/LoadingTextGenerator.swift b/SportsTime/Core/Services/LoadingTextGenerator.swift deleted file mode 100644 index 75ab0a2..0000000 --- a/SportsTime/Core/Services/LoadingTextGenerator.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// LoadingTextGenerator.swift -// SportsTime -// -// Generates unique loading messages using Apple Foundation Models. -// Falls back to predefined messages on unsupported devices. -// - -import Foundation -#if canImport(FoundationModels) -import FoundationModels -#endif - -actor LoadingTextGenerator { - - static let shared = LoadingTextGenerator() - - private static let fallbackMessages = [ - "Hang tight, we're finding the best routes...", - "Scanning stadiums across the country...", - "Building your dream road trip...", - "Calculating the perfect game day schedule...", - "Finding the best matchups for you...", - "Mapping out your adventure...", - "Checking stadium schedules...", - "Putting together some epic trips...", - "Hold on, great trips incoming...", - "Crunching the numbers on routes...", - "Almost there, planning magic happening...", - "Finding games you'll love..." - ] - - private var usedMessages: Set = [] - - /// Generates a unique loading message. - /// Uses Foundation Models if available, falls back to predefined messages. - func generateMessage() async -> String { - #if canImport(FoundationModels) - // Try Foundation Models first - if let message = await generateWithFoundationModels() { - return message - } - #endif - - // Fall back to predefined messages - return getNextFallbackMessage() - } - - #if canImport(FoundationModels) - private func generateWithFoundationModels() async -> String? { - // Check availability - guard case .available = SystemLanguageModel.default.availability else { - return nil - } - - do { - let session = LanguageModelSession(instructions: """ - Generate a short, friendly loading message for a sports road trip planning app. - The message should be casual, fun, and 8-12 words. - Don't use emojis. Don't start with "We're" or "We are". - Examples: "Hang tight, finding the best routes for you...", - "Calculating the perfect game day adventure...", - "Almost there, great trips are brewing..." - """ - ) - - let response = try await session.respond(to: "Generate one loading message") - let message = response.content.trimmingCharacters(in: .whitespacesAndNewlines) - - // Validate message isn't empty and is reasonable length - guard message.count >= 10 && message.count <= 80 else { - return nil - } - - return message - } catch { - return nil - } - } - #endif - - private func getNextFallbackMessage() -> String { - // Reset if we've used all messages - if usedMessages.count >= Self.fallbackMessages.count { - usedMessages.removeAll() - } - - // Pick a random unused message - let availableMessages = Self.fallbackMessages.filter { !usedMessages.contains($0) } - let message = availableMessages.randomElement() ?? Self.fallbackMessages[0] - usedMessages.insert(message) - return message - } - - /// Reset used messages (for testing or new session) - func reset() { - usedMessages.removeAll() - } -} diff --git a/SportsTime/Core/Services/SuggestedTripsGenerator.swift b/SportsTime/Core/Services/SuggestedTripsGenerator.swift index 6d72d0b..be22fbd 100644 --- a/SportsTime/Core/Services/SuggestedTripsGenerator.swift +++ b/SportsTime/Core/Services/SuggestedTripsGenerator.swift @@ -48,7 +48,6 @@ final class SuggestedTripsGenerator { // MARK: - Dependencies private let dataProvider = AppDataProvider.shared - private let loadingTextGenerator = LoadingTextGenerator.shared // MARK: - Grouped Trips @@ -78,8 +77,8 @@ final class SuggestedTripsGenerator { error = nil suggestedTrips = [] - // Start with a loading message - loadingMessage = await loadingTextGenerator.generateMessage() + // Set loading message + loadingMessage = "Finding the best routes..." // Ensure data is loaded if dataProvider.teams.isEmpty { @@ -141,7 +140,6 @@ final class SuggestedTripsGenerator { } func refreshTrips() async { - await loadingTextGenerator.reset() await generateTrips() } diff --git a/SportsTime/Core/Theme/AnimatedComponents.swift b/SportsTime/Core/Theme/AnimatedComponents.swift index cfadfa5..60fa571 100644 --- a/SportsTime/Core/Theme/AnimatedComponents.swift +++ b/SportsTime/Core/Theme/AnimatedComponents.swift @@ -103,66 +103,6 @@ struct AnimatedRouteGraphic: View { } } -// MARK: - Themed Spinner - -/// A custom animated spinner matching the app's visual style -/// Both ThemedSpinner and ThemedSpinnerCompact use the same visual style for consistency -struct ThemedSpinner: View { - var size: CGFloat = 40 - var lineWidth: CGFloat = 4 - var color: Color = Theme.warmOrange - - @State private var rotation: Double = 0 - - var body: some View { - ZStack { - // Background track - Circle() - .stroke(color.opacity(0.2), lineWidth: lineWidth) - - // Animated arc - Circle() - .trim(from: 0, to: 0.7) - .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - .rotationEffect(.degrees(rotation)) - } - .frame(width: size, height: size) - .onAppear { - withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) { - rotation = 360 - } - } - } -} - -/// Compact themed spinner for inline use (same style as ThemedSpinner, smaller default) -struct ThemedSpinnerCompact: View { - var size: CGFloat = 20 - var color: Color = Theme.warmOrange - - @State private var rotation: Double = 0 - - var body: some View { - ZStack { - // Background track - Circle() - .stroke(color.opacity(0.2), lineWidth: size > 16 ? 2.5 : 2) - - // Animated arc - Circle() - .trim(from: 0, to: 0.7) - .stroke(color, style: StrokeStyle(lineWidth: size > 16 ? 2.5 : 2, lineCap: .round)) - .rotationEffect(.degrees(rotation)) - } - .frame(width: size, height: size) - .onAppear { - withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) { - rotation = 360 - } - } - } -} - // MARK: - Pulsing Dot struct PulsingDot: View { @@ -242,42 +182,6 @@ struct RoutePreviewStrip: View { } } -// MARK: - Planning Progress View - -struct PlanningProgressView: View { - @State private var currentStep = 0 - let steps = ["Finding games...", "Calculating routes...", "Optimizing itinerary..."] - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - VStack(spacing: 24) { - // Themed spinner - ThemedSpinner(size: 56, lineWidth: 5) - - // Current step text - Text(steps[currentStep]) - .font(.body) - .foregroundStyle(Theme.textSecondary(colorScheme)) - .animation(.easeInOut, value: currentStep) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - .task { - await animateSteps() - } - } - - private func animateSteps() async { - while !Task.isCancelled { - try? await Task.sleep(for: .milliseconds(1500)) - guard !Task.isCancelled else { break } - withAnimation(.easeInOut) { - currentStep = (currentStep + 1) % steps.count - } - } - } -} - // MARK: - Stat Pill struct StatPill: View { @@ -345,96 +249,8 @@ struct EmptyStateView: View { } } -// MARK: - Loading Overlay - -/// A modal loading overlay with progress indication -/// Reusable pattern from PDF export overlay -struct LoadingOverlay: View { - let message: String - var detail: String? - var progress: Double? - var icon: String = "hourglass" - - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - ZStack { - // Background dimmer - Color.black.opacity(0.6) - .ignoresSafeArea() - - // Progress card - VStack(spacing: Theme.Spacing.lg) { - // Progress ring or spinner - ZStack { - Circle() - .stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 8) - .frame(width: 80, height: 80) - - if let progress = progress { - Circle() - .trim(from: 0, to: progress) - .stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round)) - .frame(width: 80, height: 80) - .rotationEffect(.degrees(-90)) - .animation(.easeInOut(duration: 0.3), value: progress) - } else { - ThemedSpinner(size: 48, lineWidth: 5) - } - - Image(systemName: icon) - .font(.title2) - .foregroundStyle(Theme.warmOrange) - .opacity(progress != nil ? 1 : 0) - } - - VStack(spacing: Theme.Spacing.xs) { - Text(message) - .font(.headline) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - if let detail = detail { - Text(detail) - .font(.subheadline) - .foregroundStyle(Theme.textSecondary(colorScheme)) - .multilineTextAlignment(.center) - } - - if let progress = progress { - Text("\(Int(progress * 100))%") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - } - } - .padding(Theme.Spacing.xl) - .background(Theme.cardBackground(colorScheme)) - .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) - .shadow(color: .black.opacity(0.3), radius: 20, y: 10) - } - .transition(.opacity) - } -} - // MARK: - Preview -#Preview("Themed Spinners") { - VStack(spacing: 40) { - ThemedSpinner(size: 60, lineWidth: 5) - - ThemedSpinner(size: 40) - - ThemedSpinnerCompact() - - HStack(spacing: 20) { - ThemedSpinnerCompact(size: 16) - Text("Loading...") - } - } - .padding(40) - .themedBackground() -} - #Preview("Animated Components") { VStack(spacing: 40) { AnimatedRouteGraphic() @@ -442,8 +258,6 @@ struct LoadingOverlay: View { RoutePreviewStrip(cities: ["San Diego", "Los Angeles", "San Francisco", "Seattle", "Portland"]) - PlanningProgressView() - HStack { StatPill(icon: "car", value: "450 mi") StatPill(icon: "clock", value: "8h driving") @@ -455,25 +269,3 @@ struct LoadingOverlay: View { .padding() .themedBackground() } - -#Preview("Loading Overlay") { - ZStack { - Color.gray - LoadingOverlay( - message: "Planning Your Trip", - detail: "Finding the best route..." - ) - } -} - -#Preview("Loading Overlay with Progress") { - ZStack { - Color.gray - LoadingOverlay( - message: "Creating PDF", - detail: "Processing images...", - progress: 0.65, - icon: "doc.fill" - ) - } -} diff --git a/SportsTime/Core/Theme/Loading/LoadingPlaceholder.swift b/SportsTime/Core/Theme/Loading/LoadingPlaceholder.swift new file mode 100644 index 0000000..dbdcb0a --- /dev/null +++ b/SportsTime/Core/Theme/Loading/LoadingPlaceholder.swift @@ -0,0 +1,230 @@ +// +// LoadingPlaceholder.swift +// SportsTime +// +// Skeleton placeholder shapes with gentle opacity pulse animation. +// + +import SwiftUI + +enum LoadingPlaceholder { + static let animationDuration: Double = 1.2 + static let minOpacity: Double = 0.3 + static let maxOpacity: Double = 0.5 + + static func rectangle(width: CGFloat, height: CGFloat) -> PlaceholderRectangle { + PlaceholderRectangle(width: width, height: height) + } + + static func circle(diameter: CGFloat) -> PlaceholderCircle { + PlaceholderCircle(diameter: diameter) + } + + static func capsule(width: CGFloat, height: CGFloat) -> PlaceholderCapsule { + PlaceholderCapsule(width: width, height: height) + } + + static var card: PlaceholderCard { + PlaceholderCard() + } + + static var listRow: PlaceholderListRow { + PlaceholderListRow() + } +} + +// MARK: - Placeholder Rectangle + +struct PlaceholderRectangle: View { + let width: CGFloat + let height: CGFloat + + @State private var isAnimating = false + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill(placeholderColor) + .frame(width: width, height: height) + .opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity) + .onAppear { + withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { + isAnimating = true + } + } + } + + private var placeholderColor: Color { + colorScheme == .dark ? Theme.cardBackgroundElevated(colorScheme) : Theme.textMuted(colorScheme) + } +} + +// MARK: - Placeholder Circle + +struct PlaceholderCircle: View { + let diameter: CGFloat + + @State private var isAnimating = false + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Circle() + .fill(placeholderColor) + .frame(width: diameter, height: diameter) + .opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity) + .onAppear { + withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { + isAnimating = true + } + } + } + + private var placeholderColor: Color { + colorScheme == .dark ? Theme.cardBackgroundElevated(colorScheme) : Theme.textMuted(colorScheme) + } +} + +// MARK: - Placeholder Capsule + +struct PlaceholderCapsule: View { + let width: CGFloat + let height: CGFloat + + @State private var isAnimating = false + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Capsule() + .fill(placeholderColor) + .frame(width: width, height: height) + .opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity) + .onAppear { + withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { + isAnimating = true + } + } + } + + private var placeholderColor: Color { + colorScheme == .dark ? Theme.cardBackgroundElevated(colorScheme) : Theme.textMuted(colorScheme) + } +} + +// MARK: - Placeholder Card (Trip Card Skeleton) + +struct PlaceholderCard: View { + @State private var isAnimating = false + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Header row + HStack { + placeholderRect(width: 60, height: 20) + Spacer() + placeholderRect(width: 40, height: 16) + } + + // Title and subtitle + VStack(alignment: .leading, spacing: 4) { + placeholderRect(width: 120, height: 16) + placeholderRect(width: 160, height: 14) + } + + // Stats row + HStack(spacing: Theme.Spacing.sm) { + placeholderRect(width: 70, height: 14) + placeholderRect(width: 60, height: 14) + } + } + .padding(Theme.Spacing.md) + .frame(width: 200) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + .onAppear { + withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { + isAnimating = true + } + } + } + + private func placeholderRect(width: CGFloat, height: CGFloat) -> some View { + RoundedRectangle(cornerRadius: 4) + .fill(placeholderColor) + .frame(width: width, height: height) + .opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity) + } + + private var placeholderColor: Color { + colorScheme == .dark ? Theme.cardBackgroundElevated(colorScheme) : Theme.textMuted(colorScheme) + } +} + +// MARK: - Placeholder List Row + +struct PlaceholderListRow: View { + @State private var isAnimating = false + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: Theme.Spacing.md) { + placeholderCircle(diameter: 40) + + VStack(alignment: .leading, spacing: 6) { + placeholderRect(width: 140, height: 16) + placeholderRect(width: 100, height: 12) + } + + Spacer() + + placeholderRect(width: 50, height: 14) + } + .padding(Theme.Spacing.md) + .onAppear { + withAnimation(.easeInOut(duration: LoadingPlaceholder.animationDuration).repeatForever(autoreverses: true)) { + isAnimating = true + } + } + } + + private func placeholderRect(width: CGFloat, height: CGFloat) -> some View { + RoundedRectangle(cornerRadius: 4) + .fill(placeholderColor) + .frame(width: width, height: height) + .opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity) + } + + private func placeholderCircle(diameter: CGFloat) -> some View { + Circle() + .fill(placeholderColor) + .frame(width: diameter, height: diameter) + .opacity(isAnimating ? LoadingPlaceholder.maxOpacity : LoadingPlaceholder.minOpacity) + } + + private var placeholderColor: Color { + colorScheme == .dark ? Theme.cardBackgroundElevated(colorScheme) : Theme.textMuted(colorScheme) + } +} + +// MARK: - Preview + +#Preview("Loading Placeholders") { + VStack(spacing: 30) { + HStack(spacing: 20) { + LoadingPlaceholder.rectangle(width: 100, height: 20) + LoadingPlaceholder.circle(diameter: 40) + LoadingPlaceholder.capsule(width: 80, height: 24) + } + + LoadingPlaceholder.card + + LoadingPlaceholder.listRow + .background(Theme.cardBackground(.dark)) + } + .padding(20) + .themedBackground() +} diff --git a/SportsTime/Core/Theme/Loading/LoadingSheet.swift b/SportsTime/Core/Theme/Loading/LoadingSheet.swift new file mode 100644 index 0000000..8a78267 --- /dev/null +++ b/SportsTime/Core/Theme/Loading/LoadingSheet.swift @@ -0,0 +1,69 @@ +// +// LoadingSheet.swift +// SportsTime +// +// Full-screen blocking loading overlay with centered card. +// + +import SwiftUI + +struct LoadingSheet: View { + static let backgroundOpacity: Double = 0.5 + + let label: String + let detail: String? + + @Environment(\.colorScheme) private var colorScheme + + init(label: String, detail: String? = nil) { + self.label = label + self.detail = detail + } + + var body: some View { + ZStack { + // Dimmed background + Color.black.opacity(Self.backgroundOpacity) + .ignoresSafeArea() + + // Centered card + VStack(spacing: Theme.Spacing.lg) { + LoadingSpinner(size: .large) + + VStack(spacing: Theme.Spacing.xs) { + Text(label) + .font(.headline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + if let detail { + Text(detail) + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .multilineTextAlignment(.center) + } + } + } + .padding(Theme.Spacing.xl) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .shadow(color: .black.opacity(0.2), radius: 16, y: 8) + } + .transition(.opacity) + } +} + +// MARK: - Preview + +#Preview("Loading Sheet") { + ZStack { + Color.gray.ignoresSafeArea() + LoadingSheet(label: "Planning trip") + } +} + +#Preview("Loading Sheet with Detail") { + ZStack { + Color.gray.ignoresSafeArea() + LoadingSheet(label: "Exporting PDF", detail: "Generating maps...") + } +} diff --git a/SportsTime/Core/Theme/Loading/LoadingSpinner.swift b/SportsTime/Core/Theme/Loading/LoadingSpinner.swift new file mode 100644 index 0000000..b1b6057 --- /dev/null +++ b/SportsTime/Core/Theme/Loading/LoadingSpinner.swift @@ -0,0 +1,93 @@ +// +// LoadingSpinner.swift +// SportsTime +// +// Apple-style indeterminate spinner with theme-aware colors. +// + +import SwiftUI + +struct LoadingSpinner: View { + enum Size { + case small, medium, large + + var diameter: CGFloat { + switch self { + case .small: return 16 + case .medium: return 24 + case .large: return 40 + } + } + + var strokeWidth: CGFloat { + switch self { + case .small: return 2 + case .medium: return 3 + case .large: return 4 + } + } + + var labelFont: Font { + switch self { + case .small, .medium: return .subheadline + case .large: return .body + } + } + } + + let size: Size + let label: String? + + @State private var rotation: Double = 0 + @Environment(\.colorScheme) private var colorScheme + + init(size: Size = .medium, label: String? = nil) { + self.size = size + self.label = label + } + + var body: some View { + HStack(spacing: Theme.Spacing.sm) { + spinnerView + + if let label { + Text(label) + .font(size.labelFont) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + } + } + + private var spinnerView: some View { + ZStack { + // Background track - subtle gray like Apple's native spinner + Circle() + .stroke(Color.secondary.opacity(0.2), lineWidth: size.strokeWidth) + + // Rotating arc (270 degrees) - gray like Apple's ProgressView + Circle() + .trim(from: 0, to: 0.75) + .stroke(Color.secondary, style: StrokeStyle(lineWidth: size.strokeWidth, lineCap: .round)) + .rotationEffect(.degrees(rotation)) + } + .frame(width: size.diameter, height: size.diameter) + .onAppear { + withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { + rotation = 360 + } + } + } +} + +// MARK: - Preview + +#Preview("Loading Spinner Sizes") { + VStack(spacing: 40) { + LoadingSpinner(size: .small, label: "Loading...") + LoadingSpinner(size: .medium, label: "Loading games...") + LoadingSpinner(size: .large, label: "Planning trip") + LoadingSpinner(size: .medium) + } + .padding(40) + .themedBackground() +} diff --git a/SportsTime/Export/Services/ProgressCardGenerator.swift b/SportsTime/Export/Services/ProgressCardGenerator.swift index a9cb144..3181d85 100644 --- a/SportsTime/Export/Services/ProgressCardGenerator.swift +++ b/SportsTime/Export/Services/ProgressCardGenerator.swift @@ -541,7 +541,8 @@ struct ProgressShareView: View { } label: { HStack { if isGenerating { - ThemedSpinnerCompact(size: 18, color: .white) + LoadingSpinner(size: .small) + .colorScheme(.dark) } else { Image(systemName: "square.and.arrow.up") } diff --git a/SportsTime/Features/Home/Views/LoadingTripsView.swift b/SportsTime/Features/Home/Views/LoadingTripsView.swift index ac4040f..e8e98fb 100644 --- a/SportsTime/Features/Home/Views/LoadingTripsView.swift +++ b/SportsTime/Features/Home/Views/LoadingTripsView.swift @@ -2,7 +2,7 @@ // LoadingTripsView.swift // SportsTime // -// Animated loading state for suggested trips carousel. +// Loading state for suggested trips carousel using skeleton placeholders. // import SwiftUI @@ -10,7 +10,6 @@ import SwiftUI struct LoadingTripsView: View { let message: String @Environment(\.colorScheme) private var colorScheme - @State private var animationPhase: Double = 0 var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.sm) { @@ -22,125 +21,26 @@ struct LoadingTripsView: View { Spacer() } - // Loading message with animation - HStack(spacing: Theme.Spacing.sm) { - LoadingDots() - - Text(message) - .font(.body) - .foregroundStyle(Theme.textSecondary(colorScheme)) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical, Theme.Spacing.xs) + // Loading indicator with message + LoadingSpinner(size: .small, label: message) + .padding(.vertical, Theme.Spacing.xs) // Placeholder cards ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: Theme.Spacing.md) { - ForEach(0..<3, id: \.self) { index in - PlaceholderCard(animationPhase: animationPhase, index: index) + ForEach(0..<3, id: \.self) { _ in + LoadingPlaceholder.card } } } } - .onAppear { - withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { - animationPhase = 1 - } - } - } -} - -// MARK: - Loading Dots - -struct LoadingDots: View { - @State private var dotIndex = 0 - - var body: some View { - HStack(spacing: 4) { - ForEach(0..<3, id: \.self) { index in - Circle() - .fill(Theme.warmOrange) - .frame(width: 6, height: 6) - .opacity(index == dotIndex ? 1.0 : 0.3) - } - } - .onAppear { - Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true) { _ in - withAnimation(.easeInOut(duration: 0.2)) { - dotIndex = (dotIndex + 1) % 3 - } - } - } - } -} - -// MARK: - Placeholder Card - -struct PlaceholderCard: View { - let animationPhase: Double - let index: Int - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - // Header placeholder - HStack { - shimmerRectangle(width: 60, height: 20) - Spacer() - shimmerRectangle(width: 40, height: 16) - } - - // Route placeholder - VStack(alignment: .leading, spacing: 4) { - shimmerRectangle(width: 100, height: 14) - shimmerRectangle(width: 20, height: 10) - shimmerRectangle(width: 80, height: 14) - } - - // Stats placeholder - HStack(spacing: Theme.Spacing.sm) { - shimmerRectangle(width: 70, height: 14) - shimmerRectangle(width: 60, height: 14) - } - - // Date placeholder - shimmerRectangle(width: 120, height: 12) - } - .padding(Theme.Spacing.md) - .frame(width: 200) - .background(Theme.cardBackground(colorScheme)) - .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) - .overlay { - RoundedRectangle(cornerRadius: Theme.CornerRadius.large) - .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) - } - } - - private func shimmerRectangle(width: CGFloat, height: CGFloat) -> some View { - RoundedRectangle(cornerRadius: 4) - .fill(shimmerGradient) - .frame(width: width, height: height) - } - - private var shimmerGradient: LinearGradient { - let baseColor = Theme.textMuted(colorScheme).opacity(0.2) - let highlightColor = Theme.textMuted(colorScheme).opacity(0.4) - - // Offset based on animation phase and index for staggered effect - let offset = animationPhase + Double(index) * 0.2 - - return LinearGradient( - colors: [baseColor, highlightColor, baseColor], - startPoint: UnitPoint(x: offset - 0.5, y: 0), - endPoint: UnitPoint(x: offset + 0.5, y: 0) - ) } } #Preview { VStack { - LoadingTripsView(message: "Hang tight, we're finding the best routes...") + LoadingTripsView(message: "Loading trips...") .padding() } + .themedBackground() } diff --git a/SportsTime/Features/Progress/Views/GamesHistoryView.swift b/SportsTime/Features/Progress/Views/GamesHistoryView.swift index 91f5fca..6350f9d 100644 --- a/SportsTime/Features/Progress/Views/GamesHistoryView.swift +++ b/SportsTime/Features/Progress/Views/GamesHistoryView.swift @@ -14,7 +14,7 @@ struct GamesHistoryView: View { selectedVisit: $selectedVisit ) } else { - ProgressView("Loading games...") + LoadingSpinner(size: .medium, label: "Loading games...") } } .navigationTitle("Games Attended") diff --git a/SportsTime/Features/Progress/Views/PhotoImportView.swift b/SportsTime/Features/Progress/Views/PhotoImportView.swift index 6578547..81d38fe 100644 --- a/SportsTime/Features/Progress/Views/PhotoImportView.swift +++ b/SportsTime/Features/Progress/Views/PhotoImportView.swift @@ -23,7 +23,7 @@ struct PhotoImportView: View { var body: some View { NavigationStack { - VStack(spacing: 0) { + Group { if viewModel.isProcessing { processingView } else if viewModel.processedPhotos.isEmpty { @@ -32,6 +32,7 @@ struct PhotoImportView: View { resultsView } } + .frame(maxWidth: .infinity, maxHeight: .infinity) .themedBackground() .navigationTitle("Import from Photos") .navigationBarTitleDisplayMode(.inline) @@ -161,15 +162,17 @@ struct PhotoImportView: View { VStack(spacing: Theme.Spacing.lg) { Spacer() - ThemedSpinner(size: 50, lineWidth: 4) + LoadingSpinner(size: .large) - Text("Processing photos...") - .font(.body) - .foregroundStyle(Theme.textSecondary(colorScheme)) + VStack(spacing: Theme.Spacing.xs) { + Text("Processing Photos") + .font(.headline) + .foregroundStyle(Theme.textPrimary(colorScheme)) - Text("\(viewModel.processedCount) of \(viewModel.totalCount) photos") - .font(.subheadline) - .foregroundStyle(Theme.textMuted(colorScheme)) + Text("\(viewModel.processedCount) of \(viewModel.totalCount)") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } Spacer() } diff --git a/SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift b/SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift index 8566b11..e298fb4 100644 --- a/SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift +++ b/SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift @@ -14,7 +14,7 @@ struct StadiumVisitHistoryView: View { NavigationStack { Group { if isLoading { - ProgressView() + LoadingSpinner(size: .medium) } else if visits.isEmpty { EmptyVisitHistoryView() } else { diff --git a/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift b/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift index ca464b0..9df8c17 100644 --- a/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift +++ b/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift @@ -232,8 +232,7 @@ struct StadiumVisitSheet: View { } label: { HStack { if isLookingUpGame { - ProgressView() - .scaleEffect(0.8) + LoadingSpinner(size: .small) } else { Image(systemName: "magnifyingglass") } diff --git a/SportsTime/Features/Schedule/Views/ScheduleListView.swift b/SportsTime/Features/Schedule/Views/ScheduleListView.swift index e6401a8..bc95e50 100644 --- a/SportsTime/Features/Schedule/Views/ScheduleListView.swift +++ b/SportsTime/Features/Schedule/Views/ScheduleListView.swift @@ -141,7 +141,7 @@ struct ScheduleListView: View { private var loadingView: some View { VStack(spacing: 16) { - ThemedSpinner(size: 44) + LoadingSpinner(size: .large) Text("Loading schedule...") .foregroundStyle(.secondary) } diff --git a/SportsTime/Features/Trip/Views/TripCreationView.swift b/SportsTime/Features/Trip/Views/TripCreationView.swift index 17d27ad..c46f304 100644 --- a/SportsTime/Features/Trip/Views/TripCreationView.swift +++ b/SportsTime/Features/Trip/Views/TripCreationView.swift @@ -420,7 +420,7 @@ struct TripCreationView: View { ThemedSection(title: "Select Games") { if viewModel.isLoadingGames || viewModel.availableGames.isEmpty { HStack(spacing: Theme.Spacing.sm) { - ThemedSpinnerCompact(size: 20) + LoadingSpinner(size: .small) Text("Loading games...") .font(.body) .foregroundStyle(Theme.textSecondary(colorScheme)) @@ -865,14 +865,7 @@ struct TripCreationView: View { } private var planningOverlay: some View { - ZStack { - Color.black.opacity(0.5) - .ignoresSafeArea() - - PlanningProgressView() - .padding(40) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24)) - } + LoadingSheet(label: "Planning trip") } private var planButton: some View { @@ -1219,7 +1212,7 @@ struct LazyTeamSection: View { Spacer() if isLoading { - ThemedSpinnerCompact(size: 14) + LoadingSpinner(size: .small) } else if selectedCount > 0 { Text("\(selectedCount)") .font(.caption2) @@ -1245,7 +1238,7 @@ struct LazyTeamSection: View { if isExpanded { if isLoading { HStack { - ThemedSpinnerCompact(size: 16) + LoadingSpinner(size: .small) Text("Loading games...") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) @@ -1407,7 +1400,7 @@ struct LocationSearchSheet: View { .textFieldStyle(.plain) .autocorrectionDisabled() if isSearching { - ThemedSpinnerCompact(size: 16) + LoadingSpinner(size: .small) } else if !searchText.isEmpty { Button { searchText = "" @@ -2032,7 +2025,7 @@ struct TripOptionCard: View { .transition(.opacity) } else if isLoadingDescription { HStack(spacing: 4) { - ThemedSpinnerCompact(size: 12) + LoadingSpinner(size: .small) Text("Generating...") .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index c39c462..ddc0152 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -198,7 +198,7 @@ struct TripDetailView: View { // Loading indicator if isLoadingRoutes { - ThemedSpinnerCompact(size: 24) + LoadingSpinner(size: .medium) .padding(.bottom, 40) } } diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift index 4220152..8b85874 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift @@ -45,11 +45,10 @@ struct ReviewStep: View { .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) Button(action: onPlan) { - HStack { + HStack(spacing: Theme.Spacing.sm) { if isPlanning { - ProgressView() - .scaleEffect(0.8) - .tint(.white) + LoadingSpinner(size: .small) + .colorScheme(.dark) // Force white on orange button } Text(isPlanning ? "Planning..." : "Plan My Trip") .fontWeight(.semibold) diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift index 14fb3bd..95f9c6b 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift @@ -28,14 +28,8 @@ struct SportsStep: View { ) if isLoading { - HStack { - ProgressView() - .scaleEffect(0.8) - Text("Checking game availability...") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - .padding(.vertical, Theme.Spacing.sm) + LoadingSpinner(size: .small, label: "Checking availability...") + .padding(.vertical, Theme.Spacing.sm) } LazyVGrid(columns: columns, spacing: Theme.Spacing.sm) { diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index defbf55..1abb973 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -152,7 +152,7 @@ struct BootstrappedContentView: View { struct BootstrapLoadingView: View { var body: some View { VStack(spacing: 20) { - ThemedSpinner(size: 50, lineWidth: 4) + LoadingSpinner(size: .large) Text("Setting up SportsTime...") .font(.headline) diff --git a/SportsTimeTests/Loading/LoadingPlaceholderTests.swift b/SportsTimeTests/Loading/LoadingPlaceholderTests.swift new file mode 100644 index 0000000..42da932 --- /dev/null +++ b/SportsTimeTests/Loading/LoadingPlaceholderTests.swift @@ -0,0 +1,37 @@ +// +// LoadingPlaceholderTests.swift +// SportsTimeTests +// + +import Testing +import SwiftUI +@testable import SportsTime + +struct LoadingPlaceholderTests { + + @Test func rectangleHasCorrectDimensions() { + let rect = LoadingPlaceholder.rectangle(width: 100, height: 20) + #expect(rect.width == 100) + #expect(rect.height == 20) + } + + @Test func circleHasCorrectDiameter() { + let circle = LoadingPlaceholder.circle(diameter: 40) + #expect(circle.diameter == 40) + } + + @Test func capsuleHasCorrectDimensions() { + let capsule = LoadingPlaceholder.capsule(width: 80, height: 24) + #expect(capsule.width == 80) + #expect(capsule.height == 24) + } + + @Test func animationCycleDurationIsCorrect() { + #expect(LoadingPlaceholder.animationDuration == 1.2) + } + + @Test func opacityRangeIsSubtle() { + #expect(LoadingPlaceholder.minOpacity == 0.3) + #expect(LoadingPlaceholder.maxOpacity == 0.5) + } +} diff --git a/SportsTimeTests/Loading/LoadingSheetTests.swift b/SportsTimeTests/Loading/LoadingSheetTests.swift new file mode 100644 index 0000000..955caa1 --- /dev/null +++ b/SportsTimeTests/Loading/LoadingSheetTests.swift @@ -0,0 +1,28 @@ +// +// LoadingSheetTests.swift +// SportsTimeTests +// + +import Testing +import SwiftUI +@testable import SportsTime + +struct LoadingSheetTests { + + @Test func sheetRequiresLabel() { + let sheet = LoadingSheet(label: "Planning trip") + #expect(sheet.label == "Planning trip") + } + + @Test func sheetCanHaveOptionalDetail() { + let withDetail = LoadingSheet(label: "Exporting", detail: "Generating maps...") + let withoutDetail = LoadingSheet(label: "Loading") + + #expect(withDetail.detail == "Generating maps...") + #expect(withoutDetail.detail == nil) + } + + @Test func backgroundOpacityIsCorrect() { + #expect(LoadingSheet.backgroundOpacity == 0.5) + } +} diff --git a/SportsTimeTests/Loading/LoadingSpinnerTests.swift b/SportsTimeTests/Loading/LoadingSpinnerTests.swift new file mode 100644 index 0000000..91ad50c --- /dev/null +++ b/SportsTimeTests/Loading/LoadingSpinnerTests.swift @@ -0,0 +1,47 @@ +// +// LoadingSpinnerTests.swift +// SportsTimeTests +// + +import Testing +import SwiftUI +@testable import SportsTime + +struct LoadingSpinnerTests { + + @Test func smallSizeHasCorrectDimensions() { + let config = LoadingSpinner.Size.small + #expect(config.diameter == 16) + #expect(config.strokeWidth == 2) + } + + @Test func mediumSizeHasCorrectDimensions() { + let config = LoadingSpinner.Size.medium + #expect(config.diameter == 24) + #expect(config.strokeWidth == 3) + } + + @Test func largeSizeHasCorrectDimensions() { + let config = LoadingSpinner.Size.large + #expect(config.diameter == 40) + #expect(config.strokeWidth == 4) + } + + @Test func spinnerCanBeCreatedWithAllSizes() { + let small = LoadingSpinner(size: .small) + let medium = LoadingSpinner(size: .medium) + let large = LoadingSpinner(size: .large) + + #expect(small.size == .small) + #expect(medium.size == .medium) + #expect(large.size == .large) + } + + @Test func spinnerCanHaveOptionalLabel() { + let withLabel = LoadingSpinner(size: .medium, label: "Loading...") + let withoutLabel = LoadingSpinner(size: .medium) + + #expect(withLabel.label == "Loading...") + #expect(withoutLabel.label == nil) + } +}