Critical:
- ProgressViewModel: use single stored ModelContext instead of creating
new ones per operation (deleteVisit silently no-op'd)
- ProgressViewModel: convert expensive computed properties to stored
with explicit recompute after mutations (3x recomputation per render)
Memory:
- AnimatedSportsIcon: replace recursive GCD asyncAfter with Task loop,
cancelled in onDisappear (19 unkillable timer chains)
- ItineraryItemService: remove [weak self] from actor Task (semantically
wrong, silently drops flushPendingUpdates)
- VisitPhotoService: remove [weak self] from @MainActor Task closures
Concurrency:
- StoreManager: replace nested MainActor.run{Task{}} with direct await
in listenForTransactions (fire-and-forget race)
- VisitPhotoService: move JPEG encoding/file writing off MainActor via
nonisolated static helper + Task.detached
- SportsIconImageGenerator: replace GCD dispatch with Task.detached for
structured concurrency compliance
Performance:
- Game/RichGame: cache DateFormatters as static lets instead of
allocating per-call (hundreds of allocations in schedule view)
- TripDetailView: wrap ~10 routeWaypoints print() in #if DEBUG, remove
2 let _ = print() from TripMapView.body (fires every render)
Accessibility:
- GameRow: add combined VoiceOver label (was reading abbreviations
letter-by-letter)
- Sport badges: add accessibilityLabel to prevent SF symbol name readout
- SportsTimeApp: post UIAccessibility.screenChanged after bootstrap
completes so VoiceOver users know app is ready
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
287 lines
10 KiB
Swift
287 lines
10 KiB
Swift
//
|
|
// SportsIconImageGenerator.swift
|
|
// SportsTime
|
|
//
|
|
// Generates a 1024x1024 PNG with randomly scattered sports icons.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct SportsIconImageGenerator {
|
|
|
|
/// All sports icons used in the animated background
|
|
private static let sportsIcons: [String] = [
|
|
// Sports balls
|
|
"football.fill",
|
|
"basketball.fill",
|
|
"baseball.fill",
|
|
"hockey.puck.fill",
|
|
"soccerball",
|
|
"tennisball.fill",
|
|
"volleyball.fill",
|
|
// Sports figures
|
|
"figure.run",
|
|
"figure.baseball",
|
|
"figure.basketball",
|
|
"figure.hockey",
|
|
"figure.soccer",
|
|
// Venues/events
|
|
"sportscourt.fill",
|
|
"trophy.fill",
|
|
"ticket.fill",
|
|
// Travel/navigation
|
|
"mappin.circle.fill",
|
|
"car.fill",
|
|
"map.fill",
|
|
"flag.checkered"
|
|
]
|
|
|
|
/// Generates a 1024x1024 PNG image with randomly scattered sports icons
|
|
/// - Returns: A UIImage with transparent background containing the icons (exactly 1024x1024 pixels)
|
|
static func generateImage() -> UIImage {
|
|
let size = CGSize(width: 1024, height: 1024)
|
|
let maxIconSize: CGFloat = 150
|
|
let minIconSize: CGFloat = 40
|
|
let minSpacing: CGFloat = 80 // Minimum distance between icon centers
|
|
|
|
// Use scale 1.0 to ensure exact pixel dimensions (not retina scaled)
|
|
let format = UIGraphicsImageRendererFormat()
|
|
format.scale = 1.0
|
|
let renderer = UIGraphicsImageRenderer(size: size, format: format)
|
|
|
|
// Generate well-distributed positions using Poisson disk sampling
|
|
let positions = generateScatteredPositions(
|
|
in: size,
|
|
minSpacing: minSpacing,
|
|
targetCount: 45
|
|
)
|
|
|
|
let image = renderer.image { context in
|
|
// Clear background (transparent)
|
|
UIColor.clear.setFill()
|
|
context.fill(CGRect(origin: .zero, size: size))
|
|
|
|
// Shuffle icons to randomize which icon appears where
|
|
let shuffledIcons = sportsIcons.shuffled()
|
|
|
|
// Draw icons at scattered positions
|
|
for (index, position) in positions.enumerated() {
|
|
let iconName = shuffledIcons[index % shuffledIcons.count]
|
|
let targetSize = CGFloat.random(in: minIconSize...maxIconSize)
|
|
let rotation = CGFloat.random(in: -30...30) * .pi / 180
|
|
|
|
// Create SF Symbol image at a large point size for quality
|
|
let config = UIImage.SymbolConfiguration(pointSize: 100, weight: .regular)
|
|
if let symbolImage = UIImage(systemName: iconName, withConfiguration: config) {
|
|
// Tint with warm orange
|
|
let tintedImage = symbolImage.withTintColor(
|
|
UIColor(Theme.warmOrange),
|
|
renderingMode: .alwaysOriginal
|
|
)
|
|
|
|
// Calculate proportional size maintaining aspect ratio
|
|
let originalSize = tintedImage.size
|
|
let aspectRatio = originalSize.width / originalSize.height
|
|
let drawSize: CGSize
|
|
if aspectRatio > 1 {
|
|
// Wider than tall - constrain by width
|
|
drawSize = CGSize(width: targetSize, height: targetSize / aspectRatio)
|
|
} else {
|
|
// Taller than wide - constrain by height
|
|
drawSize = CGSize(width: targetSize * aspectRatio, height: targetSize)
|
|
}
|
|
|
|
// Save graphics state for rotation
|
|
context.cgContext.saveGState()
|
|
|
|
// Position at the scattered point
|
|
context.cgContext.translateBy(x: position.x, y: position.y)
|
|
context.cgContext.rotate(by: rotation)
|
|
|
|
// Draw the icon centered at origin with correct aspect ratio
|
|
let drawRect = CGRect(
|
|
x: -drawSize.width / 2,
|
|
y: -drawSize.height / 2,
|
|
width: drawSize.width,
|
|
height: drawSize.height
|
|
)
|
|
tintedImage.draw(in: drawRect)
|
|
|
|
context.cgContext.restoreGState()
|
|
}
|
|
}
|
|
}
|
|
|
|
return image
|
|
}
|
|
|
|
/// Generates well-distributed points using a grid-based approach with jitter
|
|
/// This ensures icons are scattered across the entire image, not clumped together
|
|
private static func generateScatteredPositions(
|
|
in size: CGSize,
|
|
minSpacing: CGFloat,
|
|
targetCount: Int
|
|
) -> [CGPoint] {
|
|
var positions: [CGPoint] = []
|
|
|
|
// Calculate grid dimensions to achieve roughly targetCount points
|
|
let gridSize = Int(ceil(sqrt(Double(targetCount))))
|
|
let cellWidth = size.width / CGFloat(gridSize)
|
|
let cellHeight = size.height / CGFloat(gridSize)
|
|
|
|
// Margin from edges
|
|
let margin: CGFloat = 40
|
|
|
|
for row in 0..<gridSize {
|
|
for col in 0..<gridSize {
|
|
// Base position at center of grid cell
|
|
let baseCenterX = (CGFloat(col) + 0.5) * cellWidth
|
|
let baseCenterY = (CGFloat(row) + 0.5) * cellHeight
|
|
|
|
// Add random jitter within the cell (up to 40% of cell size)
|
|
let jitterX = CGFloat.random(in: -cellWidth * 0.4...cellWidth * 0.4)
|
|
let jitterY = CGFloat.random(in: -cellHeight * 0.4...cellHeight * 0.4)
|
|
|
|
var x = baseCenterX + jitterX
|
|
var y = baseCenterY + jitterY
|
|
|
|
// Clamp to stay within margins
|
|
x = max(margin, min(size.width - margin, x))
|
|
y = max(margin, min(size.height - margin, y))
|
|
|
|
// Randomly skip some cells for more organic feel (keep ~80%)
|
|
if Double.random(in: 0...1) < 0.85 {
|
|
positions.append(CGPoint(x: x, y: y))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shuffle to randomize draw order (affects overlap)
|
|
return positions.shuffled()
|
|
}
|
|
|
|
/// Generates PNG data from the image
|
|
static func generatePNGData() -> Data? {
|
|
let image = generateImage()
|
|
return image.pngData()
|
|
}
|
|
}
|
|
|
|
// MARK: - SwiftUI View for generating and sharing
|
|
|
|
struct SportsIconImageGeneratorView: View {
|
|
@State private var generatedImage: UIImage?
|
|
@State private var isGenerating = false
|
|
@State private var showShareSheet = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: 16) {
|
|
// Preview of generated image
|
|
if let image = generatedImage {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(maxWidth: 300, maxHeight: 300)
|
|
.background(
|
|
// Checkerboard pattern to show transparency
|
|
CheckerboardPattern()
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
// Generate button
|
|
Button {
|
|
generateNewImage()
|
|
} label: {
|
|
HStack {
|
|
if isGenerating {
|
|
ProgressView()
|
|
.tint(.white)
|
|
} else {
|
|
Image(systemName: "dice.fill")
|
|
}
|
|
Text(generatedImage == nil ? "Generate Image" : "Regenerate")
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Theme.warmOrange)
|
|
.foregroundStyle(.white)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
.disabled(isGenerating)
|
|
|
|
// Share button (only visible when image exists)
|
|
if generatedImage != nil {
|
|
ShareLink(
|
|
item: Image(uiImage: generatedImage!),
|
|
preview: SharePreview("Sports Icons", image: Image(uiImage: generatedImage!))
|
|
) {
|
|
HStack {
|
|
Image(systemName: "square.and.arrow.up")
|
|
Text("Share via AirDrop")
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(Color.blue)
|
|
.foregroundStyle(.white)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
private func generateNewImage() {
|
|
isGenerating = true
|
|
|
|
// Generate on background thread to avoid UI freeze
|
|
Task {
|
|
let image = await Task.detached(priority: .userInitiated) {
|
|
SportsIconImageGenerator.generateImage()
|
|
}.value
|
|
|
|
withAnimation {
|
|
generatedImage = image
|
|
isGenerating = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Checkerboard Pattern for transparency preview
|
|
|
|
private struct CheckerboardPattern: View {
|
|
var body: some View {
|
|
Canvas { context, size in
|
|
let tileSize: CGFloat = 10
|
|
let rows = Int(ceil(size.height / tileSize))
|
|
let cols = Int(ceil(size.width / tileSize))
|
|
|
|
for row in 0..<rows {
|
|
for col in 0..<cols {
|
|
let isLight = (row + col) % 2 == 0
|
|
let rect = CGRect(
|
|
x: CGFloat(col) * tileSize,
|
|
y: CGFloat(row) * tileSize,
|
|
width: tileSize,
|
|
height: tileSize
|
|
)
|
|
context.fill(
|
|
Path(rect),
|
|
with: .color(isLight ? Color.gray.opacity(0.2) : Color.gray.opacity(0.3))
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
SportsIconImageGeneratorView()
|
|
}
|