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:
230
SportsTime/Core/Theme/Loading/LoadingPlaceholder.swift
Normal file
230
SportsTime/Core/Theme/Loading/LoadingPlaceholder.swift
Normal 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()
|
||||
}
|
||||
69
SportsTime/Core/Theme/Loading/LoadingSheet.swift
Normal file
69
SportsTime/Core/Theme/Loading/LoadingSheet.swift
Normal 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...")
|
||||
}
|
||||
}
|
||||
93
SportsTime/Core/Theme/Loading/LoadingSpinner.swift
Normal file
93
SportsTime/Core/Theme/Loading/LoadingSpinner.swift
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user