feat(ui): replace loading indicators with Apple-style LoadingSpinner

- Add LoadingSpinner component with small/medium/large sizes using system gray color
- Add LoadingPlaceholder for skeleton loading states
- Add LoadingSheet for full-screen blocking overlays
- Replace ThemedSpinner/ThemedSpinnerCompact across all views
- Remove deprecated loading components from AnimatedComponents.swift
- Delete LoadingTextGenerator.swift
- Fix PhotoImportView layout to fill full width

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-12 22:43:33 -06:00
parent f8204007e6
commit c0f1645434
21 changed files with 544 additions and 460 deletions

View File

@@ -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<String> = []
/// 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()
}
}

View File

@@ -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()
}

View File

@@ -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"
)
}
}

View File

@@ -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()
}

View File

@@ -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...")
}
}

View File

@@ -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()
}

View File

@@ -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")
}

View File

@@ -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()
}

View File

@@ -14,7 +14,7 @@ struct GamesHistoryView: View {
selectedVisit: $selectedVisit
)
} else {
ProgressView("Loading games...")
LoadingSpinner(size: .medium, label: "Loading games...")
}
}
.navigationTitle("Games Attended")

View File

@@ -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()
}

View File

@@ -14,7 +14,7 @@ struct StadiumVisitHistoryView: View {
NavigationStack {
Group {
if isLoading {
ProgressView()
LoadingSpinner(size: .medium)
} else if visits.isEmpty {
EmptyVisitHistoryView()
} else {

View File

@@ -232,8 +232,7 @@ struct StadiumVisitSheet: View {
} label: {
HStack {
if isLookingUpGame {
ProgressView()
.scaleEffect(0.8)
LoadingSpinner(size: .small)
} else {
Image(systemName: "magnifyingglass")
}

View File

@@ -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)
}

View File

@@ -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))

View File

@@ -198,7 +198,7 @@ struct TripDetailView: View {
// Loading indicator
if isLoadingRoutes {
ThemedSpinnerCompact(size: 24)
LoadingSpinner(size: .medium)
.padding(.bottom, 40)
}
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}