13 tasks covering LoadingSpinner, LoadingPlaceholder, LoadingSheet creation, ProgressView replacements, and deprecated component removal. Includes TDD with 13 new tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1194 lines
32 KiB
Markdown
1194 lines
32 KiB
Markdown
# Loading System Redesign Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Replace all loading indicators with a unified, Apple-style design system using three components: LoadingSpinner, LoadingPlaceholder, and LoadingSheet.
|
|
|
|
**Architecture:** Create new Loading/ folder with three focused components. Deprecate 6 existing components in AnimatedComponents.swift. Replace 5 native ProgressView() usages. Refactor LoadingTripsView to use new skeletons.
|
|
|
|
**Tech Stack:** SwiftUI, Swift Testing
|
|
|
|
---
|
|
|
|
## Task 1: Create LoadingSpinner Component
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Core/Theme/Loading/LoadingSpinner.swift`
|
|
- Test: `SportsTimeTests/Loading/LoadingSpinnerTests.swift`
|
|
|
|
**Step 1: Create the Loading directory**
|
|
|
|
```bash
|
|
mkdir -p SportsTime/Core/Theme/Loading
|
|
mkdir -p SportsTimeTests/Loading
|
|
```
|
|
|
|
**Step 2: Write the failing test**
|
|
|
|
Create `SportsTimeTests/Loading/LoadingSpinnerTests.swift`:
|
|
|
|
```swift
|
|
//
|
|
// 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)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Run test to verify it fails**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/LoadingSpinnerTests test`
|
|
|
|
Expected: FAIL with "Cannot find 'LoadingSpinner' in scope"
|
|
|
|
**Step 4: Write the LoadingSpinner implementation**
|
|
|
|
Create `SportsTime/Core/Theme/Loading/LoadingSpinner.swift`:
|
|
|
|
```swift
|
|
//
|
|
// 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
|
|
Circle()
|
|
.stroke(Theme.warmOrange.opacity(0.15), lineWidth: size.strokeWidth)
|
|
|
|
// Rotating arc (270 degrees)
|
|
Circle()
|
|
.trim(from: 0, to: 0.75)
|
|
.stroke(Theme.warmOrange, 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()
|
|
}
|
|
```
|
|
|
|
**Step 5: Run tests to verify they pass**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/LoadingSpinnerTests test`
|
|
|
|
Expected: All 5 tests PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Theme/Loading/LoadingSpinner.swift SportsTimeTests/Loading/LoadingSpinnerTests.swift
|
|
git commit -m "$(cat <<'EOF'
|
|
feat: add LoadingSpinner component
|
|
|
|
Apple-style indeterminate spinner with three sizes (small/medium/large),
|
|
optional label, and theme-aware colors. Uses 270-degree arc with
|
|
1-second rotation.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Create LoadingPlaceholder Component
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Core/Theme/Loading/LoadingPlaceholder.swift`
|
|
- Test: `SportsTimeTests/Loading/LoadingPlaceholderTests.swift`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Create `SportsTimeTests/Loading/LoadingPlaceholderTests.swift`:
|
|
|
|
```swift
|
|
//
|
|
// 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)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/LoadingPlaceholderTests test`
|
|
|
|
Expected: FAIL with "Cannot find 'LoadingPlaceholder' in scope"
|
|
|
|
**Step 3: Write the LoadingPlaceholder implementation**
|
|
|
|
Create `SportsTime/Core/Theme/Loading/LoadingPlaceholder.swift`:
|
|
|
|
```swift
|
|
//
|
|
// 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()
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/LoadingPlaceholderTests test`
|
|
|
|
Expected: All 5 tests PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Theme/Loading/LoadingPlaceholder.swift SportsTimeTests/Loading/LoadingPlaceholderTests.swift
|
|
git commit -m "$(cat <<'EOF'
|
|
feat: add LoadingPlaceholder component
|
|
|
|
Skeleton placeholder shapes with gentle opacity pulse animation (0.3-0.5
|
|
over 1.2s). Includes rectangle, circle, capsule primitives plus card and
|
|
listRow composites. Theme-aware colors.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Create LoadingSheet Component
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Core/Theme/Loading/LoadingSheet.swift`
|
|
- Test: `SportsTimeTests/Loading/LoadingSheetTests.swift`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Create `SportsTimeTests/Loading/LoadingSheetTests.swift`:
|
|
|
|
```swift
|
|
//
|
|
// 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)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/LoadingSheetTests test`
|
|
|
|
Expected: FAIL with "Cannot find 'LoadingSheet' in scope"
|
|
|
|
**Step 3: Write the LoadingSheet implementation**
|
|
|
|
Create `SportsTime/Core/Theme/Loading/LoadingSheet.swift`:
|
|
|
|
```swift
|
|
//
|
|
// 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...")
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/LoadingSheetTests test`
|
|
|
|
Expected: All 3 tests PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Theme/Loading/LoadingSheet.swift SportsTimeTests/Loading/LoadingSheetTests.swift
|
|
git commit -m "$(cat <<'EOF'
|
|
feat: add LoadingSheet component
|
|
|
|
Full-screen blocking overlay with dimmed background (0.5 opacity),
|
|
centered card with large spinner, label, and optional detail text.
|
|
Replaces LoadingOverlay and PlanningProgressView.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Replace ProgressView in SportsStep
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift:30-38`
|
|
|
|
**Step 1: Replace ProgressView with LoadingSpinner**
|
|
|
|
In `SportsStep.swift`, replace lines 30-38:
|
|
|
|
```swift
|
|
// OLD (lines 30-38):
|
|
if isLoading {
|
|
HStack {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
Text("Checking game availability...")
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
}
|
|
.padding(.vertical, Theme.Spacing.sm)
|
|
}
|
|
```
|
|
|
|
With:
|
|
|
|
```swift
|
|
if isLoading {
|
|
LoadingSpinner(size: .small, label: "Checking availability...")
|
|
.padding(.vertical, Theme.Spacing.sm)
|
|
}
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: Build succeeds
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift
|
|
git commit -m "$(cat <<'EOF'
|
|
refactor: replace ProgressView with LoadingSpinner in SportsStep
|
|
|
|
Use new LoadingSpinner(size: .small) for consistent loading UI.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Replace ProgressView in ReviewStep
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift:48-53`
|
|
|
|
**Step 1: Replace ProgressView with LoadingSpinner**
|
|
|
|
In `ReviewStep.swift`, replace the button content (lines 48-56):
|
|
|
|
```swift
|
|
// OLD:
|
|
Button(action: onPlan) {
|
|
HStack {
|
|
if isPlanning {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
.tint(.white)
|
|
}
|
|
Text(isPlanning ? "Planning..." : "Plan My Trip")
|
|
.fontWeight(.semibold)
|
|
}
|
|
```
|
|
|
|
With:
|
|
|
|
```swift
|
|
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)
|
|
}
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: Build succeeds
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift
|
|
git commit -m "$(cat <<'EOF'
|
|
refactor: replace ProgressView with LoadingSpinner in ReviewStep
|
|
|
|
Use LoadingSpinner(size: .small) in Plan My Trip button.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Replace ProgressView in StadiumVisitHistoryView
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift:16-17`
|
|
|
|
**Step 1: Replace ProgressView with LoadingSpinner**
|
|
|
|
In `StadiumVisitHistoryView.swift`, replace line 17:
|
|
|
|
```swift
|
|
// OLD:
|
|
if isLoading {
|
|
ProgressView()
|
|
```
|
|
|
|
With:
|
|
|
|
```swift
|
|
if isLoading {
|
|
LoadingSpinner(size: .medium)
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: Build succeeds
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift
|
|
git commit -m "$(cat <<'EOF'
|
|
refactor: replace ProgressView with LoadingSpinner in StadiumVisitHistoryView
|
|
|
|
Use LoadingSpinner(size: .medium) for consistent loading UI.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Replace ProgressView in GamesHistoryView
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Progress/Views/GamesHistoryView.swift:17`
|
|
|
|
**Step 1: Replace ProgressView with LoadingSpinner**
|
|
|
|
In `GamesHistoryView.swift`, replace line 17:
|
|
|
|
```swift
|
|
// OLD:
|
|
ProgressView("Loading games...")
|
|
```
|
|
|
|
With:
|
|
|
|
```swift
|
|
LoadingSpinner(size: .medium, label: "Loading games...")
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: Build succeeds
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Progress/Views/GamesHistoryView.swift
|
|
git commit -m "$(cat <<'EOF'
|
|
refactor: replace ProgressView with LoadingSpinner in GamesHistoryView
|
|
|
|
Use LoadingSpinner with label for consistent loading UI.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Replace ProgressView in StadiumVisitSheet
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Progress/Views/StadiumVisitSheet.swift:234-236`
|
|
|
|
**Step 1: Replace ProgressView with LoadingSpinner**
|
|
|
|
In `StadiumVisitSheet.swift`, replace lines 234-236:
|
|
|
|
```swift
|
|
// OLD:
|
|
HStack {
|
|
if isLookingUpGame {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
} else {
|
|
```
|
|
|
|
With:
|
|
|
|
```swift
|
|
HStack {
|
|
if isLookingUpGame {
|
|
LoadingSpinner(size: .small)
|
|
} else {
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: Build succeeds
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Progress/Views/StadiumVisitSheet.swift
|
|
git commit -m "$(cat <<'EOF'
|
|
refactor: replace ProgressView with LoadingSpinner in StadiumVisitSheet
|
|
|
|
Use LoadingSpinner(size: .small) in Look Up Game button.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Replace planningOverlay in TripCreationView
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/TripCreationView.swift:867-876`
|
|
|
|
**Step 1: Replace PlanningProgressView with LoadingSheet**
|
|
|
|
In `TripCreationView.swift`, replace lines 867-876:
|
|
|
|
```swift
|
|
// OLD:
|
|
private var planningOverlay: some View {
|
|
ZStack {
|
|
Color.black.opacity(0.5)
|
|
.ignoresSafeArea()
|
|
|
|
PlanningProgressView()
|
|
.padding(40)
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24))
|
|
}
|
|
}
|
|
```
|
|
|
|
With:
|
|
|
|
```swift
|
|
private var planningOverlay: some View {
|
|
LoadingSheet(label: "Planning trip")
|
|
}
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: Build succeeds
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/TripCreationView.swift
|
|
git commit -m "$(cat <<'EOF'
|
|
refactor: replace PlanningProgressView with LoadingSheet
|
|
|
|
Use new LoadingSheet component for planning overlay.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Refactor LoadingTripsView to Use New Placeholders
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Home/Views/LoadingTripsView.swift`
|
|
|
|
**Step 1: Rewrite LoadingTripsView to use LoadingPlaceholder**
|
|
|
|
Replace the entire file with:
|
|
|
|
```swift
|
|
//
|
|
// LoadingTripsView.swift
|
|
// SportsTime
|
|
//
|
|
// Loading state for suggested trips carousel using skeleton placeholders.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct LoadingTripsView: View {
|
|
let message: String
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
|
// Header
|
|
HStack {
|
|
Text("Featured Trips")
|
|
.font(.title2)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
Spacer()
|
|
}
|
|
|
|
// 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) { _ in
|
|
LoadingPlaceholder.card
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
VStack {
|
|
LoadingTripsView(message: "Loading trips...")
|
|
.padding()
|
|
}
|
|
.themedBackground()
|
|
}
|
|
```
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: Build succeeds
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Home/Views/LoadingTripsView.swift
|
|
git commit -m "$(cat <<'EOF'
|
|
refactor: simplify LoadingTripsView with new loading components
|
|
|
|
Use LoadingSpinner and LoadingPlaceholder.card instead of custom
|
|
LoadingDots and PlaceholderCard with shimmer animation.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Remove Deprecated Components from AnimatedComponents.swift
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Core/Theme/AnimatedComponents.swift`
|
|
|
|
**Step 1: Remove deprecated loading components**
|
|
|
|
Remove the following from `AnimatedComponents.swift`:
|
|
- `ThemedSpinner` (lines 106-136)
|
|
- `ThemedSpinnerCompact` (lines 138-164)
|
|
- `PlanningProgressView` (lines 245-279)
|
|
- `LoadingOverlay` (lines 348-417)
|
|
- Preview for "Themed Spinners" (lines 419-436)
|
|
- Preview for "Loading Overlay" (lines 459-467)
|
|
- Preview for "Loading Overlay with Progress" (lines 469-479)
|
|
|
|
Keep:
|
|
- `AnimatedRouteGraphic`
|
|
- `PulsingDot`
|
|
- `RoutePreviewStrip`
|
|
- `StatPill`
|
|
- `EmptyStateView`
|
|
- Their previews
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: Build succeeds
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Theme/AnimatedComponents.swift
|
|
git commit -m "$(cat <<'EOF'
|
|
refactor: remove deprecated loading components from AnimatedComponents
|
|
|
|
Remove ThemedSpinner, ThemedSpinnerCompact, PlanningProgressView,
|
|
and LoadingOverlay. These are replaced by the new Loading/ components.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Delete LoadingTextGenerator.swift
|
|
|
|
**Files:**
|
|
- Delete: `SportsTime/Core/Services/LoadingTextGenerator.swift`
|
|
|
|
**Step 1: Remove the file**
|
|
|
|
```bash
|
|
git rm SportsTime/Core/Services/LoadingTextGenerator.swift
|
|
```
|
|
|
|
**Step 2: Build to verify no references remain**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: Build succeeds (if there are errors, search for `LoadingTextGenerator` usages and remove them)
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git commit -m "$(cat <<'EOF'
|
|
refactor: remove LoadingTextGenerator
|
|
|
|
Loading messages are now static context-specific labels,
|
|
no longer AI-generated or rotating.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Delete LoadingDots and PlaceholderCard (Cleanup)
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Home/Views/LoadingTripsView.swift` (if still contains old components)
|
|
|
|
**Step 1: Verify LoadingDots and PlaceholderCard are no longer in codebase**
|
|
|
|
```bash
|
|
grep -r "LoadingDots\|PlaceholderCard" SportsTime/
|
|
```
|
|
|
|
If any usages remain, remove them.
|
|
|
|
**Step 2: Build full test suite**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test`
|
|
|
|
Expected: All tests pass
|
|
|
|
**Step 3: Final commit if needed**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "$(cat <<'EOF'
|
|
chore: final cleanup of deprecated loading components
|
|
|
|
Remove any remaining references to LoadingDots and PlaceholderCard.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| Task | Description | Tests Added |
|
|
|------|-------------|-------------|
|
|
| 1 | Create LoadingSpinner | 5 |
|
|
| 2 | Create LoadingPlaceholder | 5 |
|
|
| 3 | Create LoadingSheet | 3 |
|
|
| 4 | Replace ProgressView in SportsStep | - |
|
|
| 5 | Replace ProgressView in ReviewStep | - |
|
|
| 6 | Replace ProgressView in StadiumVisitHistoryView | - |
|
|
| 7 | Replace ProgressView in GamesHistoryView | - |
|
|
| 8 | Replace ProgressView in StadiumVisitSheet | - |
|
|
| 9 | Replace planningOverlay in TripCreationView | - |
|
|
| 10 | Refactor LoadingTripsView | - |
|
|
| 11 | Remove deprecated components | - |
|
|
| 12 | Delete LoadingTextGenerator | - |
|
|
| 13 | Final cleanup and verification | - |
|
|
|
|
**Total new tests:** 13
|
|
**Files created:** 6 (3 components + 3 test files)
|
|
**Files modified:** 8
|
|
**Files deleted:** 1 (LoadingTextGenerator.swift)
|
|
**Components removed:** 6 (ThemedSpinner, ThemedSpinnerCompact, LoadingDots, PlaceholderCard, PlanningProgressView, LoadingOverlay)
|