fix: codebase audit fixes — safety, accessibility, and production hygiene
Address 16 issues from external audit: - Move StoreKit transaction listener ownership to StoreManager singleton with proper deinit - Remove noisy VoiceOver announcements, add missing accessibility on StatPill and BootstrapLoadingView - Replace String @retroactive Identifiable with IdentifiableShareCode wrapper - Add crash guard in AchievementEngine getContributingVisitIds + cache stadium lookups - Pre-compute GamesHistoryViewModel filtered properties to avoid redundant SwiftUI recomputation - Remove force-unwraps in ProgressMapView with safe guard-let fallback - Add diff-based update gating in ItineraryTableViewWrapper to prevent unnecessary reloads - Replace deprecated UIScreen.main with UIWindowScene lookup - Add deinit task cancellation in ScheduleViewModel and SuggestedTripsGenerator - Wrap ~234 unguarded print() calls across 27 files in #if DEBUG Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -636,27 +636,37 @@ nonisolated struct CKTripPoll {
|
||||
guard let pollIdString = record[CKTripPoll.pollIdKey] as? String,
|
||||
let pollId = UUID(uuidString: pollIdString)
|
||||
else {
|
||||
#if DEBUG
|
||||
print("CKTripPoll.toPoll: Failed to parse pollId")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let title = record[CKTripPoll.titleKey] as? String else {
|
||||
#if DEBUG
|
||||
print("CKTripPoll.toPoll: Missing title for poll \(pollId)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let ownerId = record[CKTripPoll.ownerIdKey] as? String else {
|
||||
#if DEBUG
|
||||
print("CKTripPoll.toPoll: Missing ownerId for poll \(pollId)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let shareCode = record[CKTripPoll.shareCodeKey] as? String else {
|
||||
#if DEBUG
|
||||
print("CKTripPoll.toPoll: Missing shareCode for poll \(pollId)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let createdAt = record[CKTripPoll.createdAtKey] as? Date else {
|
||||
#if DEBUG
|
||||
print("CKTripPoll.toPoll: Missing createdAt for poll \(pollId)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -669,11 +679,15 @@ nonisolated struct CKTripPoll {
|
||||
do {
|
||||
tripSnapshots = try JSONDecoder().decode([Trip].self, from: tripsData)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("CKTripPoll.toPoll: Failed to decode tripSnapshots for poll \(pollId): \(error)")
|
||||
#endif
|
||||
// Return poll with empty trips rather than failing completely
|
||||
}
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("CKTripPoll.toPoll: Missing tripSnapshots data for poll \(pollId)")
|
||||
#endif
|
||||
}
|
||||
|
||||
var poll = TripPoll(
|
||||
|
||||
@@ -30,6 +30,7 @@ final class AchievementEngine {
|
||||
|
||||
private let modelContext: ModelContext
|
||||
private let dataProvider: AppDataProvider
|
||||
private var stadiumIdsBySport: [Sport: [String]]?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -356,6 +357,7 @@ final class AchievementEngine {
|
||||
case .visitsInDays(let requiredVisits, let days):
|
||||
// Find the qualifying window of visits
|
||||
let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate }
|
||||
guard sortedVisits.count >= requiredVisits else { return [] }
|
||||
for i in 0...(sortedVisits.count - requiredVisits) {
|
||||
let windowStart = sortedVisits[i].visitDate
|
||||
let windowEnd = sortedVisits[i + requiredVisits - 1].visitDate
|
||||
@@ -399,15 +401,15 @@ final class AchievementEngine {
|
||||
}
|
||||
|
||||
private func getStadiumIdsForLeague(_ sport: Sport) -> [String] {
|
||||
// Get all stadium canonical IDs for this sport
|
||||
return dataProvider.stadiums
|
||||
.filter { stadium in
|
||||
// Check if stadium hosts teams of this sport
|
||||
dataProvider.teams.contains { team in
|
||||
team.stadiumId == stadium.id && team.sport == sport
|
||||
}
|
||||
// Build cache lazily on first access
|
||||
if stadiumIdsBySport == nil {
|
||||
var cache: [Sport: [String]] = [:]
|
||||
for team in dataProvider.teams {
|
||||
cache[team.sport, default: []].append(team.stadiumId)
|
||||
}
|
||||
.map { $0.id }
|
||||
stadiumIdsBySport = cache
|
||||
}
|
||||
return stadiumIdsBySport?[sport] ?? []
|
||||
}
|
||||
|
||||
// MARK: - Data Fetching
|
||||
|
||||
@@ -24,14 +24,18 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
) {
|
||||
#if DEBUG
|
||||
print("📡 [Push] Registered for remote notifications")
|
||||
#endif
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error
|
||||
) {
|
||||
#if DEBUG
|
||||
print("📡 [Push] Failed to register: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
|
||||
func application(
|
||||
|
||||
@@ -84,7 +84,9 @@ final class BackgroundSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("Background tasks registered")
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Task Scheduling
|
||||
@@ -100,9 +102,13 @@ final class BackgroundSyncManager {
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
#if DEBUG
|
||||
print("Background refresh scheduled")
|
||||
#endif
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Failed to schedule background refresh: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,9 +135,13 @@ final class BackgroundSyncManager {
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
#if DEBUG
|
||||
print("Overnight processing task scheduled for \(targetDate)")
|
||||
#endif
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Failed to schedule processing task: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,11 +529,15 @@ final class BackgroundSyncManager {
|
||||
}
|
||||
|
||||
try context.save()
|
||||
#if DEBUG
|
||||
print("Background cleanup: Archived \(oldGames.count) old games")
|
||||
#endif
|
||||
return true
|
||||
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Background cleanup failed: \(error.localizedDescription)")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,9 @@ final class AppDataProvider: ObservableObject {
|
||||
func filterRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
|
||||
let games = try await filterGames(sports: sports, startDate: startDate, endDate: endDate)
|
||||
|
||||
#if DEBUG
|
||||
print("🎮 [DATA] filterRichGames: \(games.count) games from SwiftData for \(sports.map(\.rawValue).joined(separator: ", "))")
|
||||
#endif
|
||||
|
||||
var richGames: [RichGame] = []
|
||||
var droppedGames: [(game: Game, reason: String)] = []
|
||||
@@ -243,6 +245,7 @@ final class AppDataProvider: ObservableObject {
|
||||
richGames.append(RichGame(game: game, homeTeam: homeTeam!, awayTeam: awayTeam!, stadium: resolvedStadium!))
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if !droppedGames.isEmpty {
|
||||
print("⚠️ [DATA] Dropped \(droppedGames.count) games due to missing lookups:")
|
||||
for (game, reason) in droppedGames.prefix(10) {
|
||||
@@ -257,6 +260,7 @@ final class AppDataProvider: ObservableObject {
|
||||
}
|
||||
|
||||
print("🎮 [DATA] Returning \(richGames.count) rich games")
|
||||
#endif
|
||||
return richGames
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Identifiable Share Code
|
||||
|
||||
struct IdentifiableShareCode: Identifiable {
|
||||
let id: String
|
||||
var value: String { id }
|
||||
}
|
||||
|
||||
// MARK: - Deep Link Handler
|
||||
|
||||
@MainActor
|
||||
@@ -19,7 +26,7 @@ final class DeepLinkHandler {
|
||||
|
||||
/// The pending poll share code from a deep link
|
||||
/// Setting to nil clears the pending state
|
||||
var pendingPollShareCode: String? {
|
||||
var pendingPollShareCode: IdentifiableShareCode? {
|
||||
didSet {
|
||||
if pendingPollShareCode == nil {
|
||||
pendingPoll = nil
|
||||
@@ -49,7 +56,7 @@ final class DeepLinkHandler {
|
||||
|
||||
switch destination {
|
||||
case .poll(let shareCode):
|
||||
pendingPollShareCode = shareCode
|
||||
pendingPollShareCode = IdentifiableShareCode(id: shareCode)
|
||||
}
|
||||
|
||||
return destination
|
||||
|
||||
@@ -293,6 +293,7 @@ final class GameMatcher {
|
||||
metadata: PhotoMetadata,
|
||||
sport: Sport? = nil
|
||||
) async -> PhotoImportCandidate {
|
||||
#if DEBUG
|
||||
print("🎯 [GameMatcher] Processing photo for import")
|
||||
print("🎯 [GameMatcher] - hasValidDate: \(metadata.hasValidDate)")
|
||||
print("🎯 [GameMatcher] - captureDate: \(metadata.captureDate?.description ?? "nil")")
|
||||
@@ -300,6 +301,7 @@ final class GameMatcher {
|
||||
if let coords = metadata.coordinates {
|
||||
print("🎯 [GameMatcher] - coordinates: \(coords.latitude), \(coords.longitude)")
|
||||
}
|
||||
#endif
|
||||
|
||||
// Get stadium matches regardless of game matching
|
||||
var stadiumMatches: [StadiumMatch] = []
|
||||
@@ -308,16 +310,21 @@ final class GameMatcher {
|
||||
coordinates: coordinates,
|
||||
sport: sport
|
||||
)
|
||||
#if DEBUG
|
||||
print("🎯 [GameMatcher] - nearby stadiums found: \(stadiumMatches.count)")
|
||||
for match in stadiumMatches.prefix(3) {
|
||||
print("🎯 [GameMatcher] • \(match.stadium.name) (\(String(format: "%.1f", match.distance / 1609.34)) mi)")
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("🎯 [GameMatcher] - no coordinates, skipping stadium proximity search")
|
||||
#endif
|
||||
}
|
||||
|
||||
var matchResult = await matchGame(metadata: metadata, sport: sport)
|
||||
|
||||
#if DEBUG
|
||||
switch matchResult {
|
||||
case .singleMatch(let match):
|
||||
print("🎯 [GameMatcher] - result: singleMatch - \(match.homeTeam.fullName) vs \(match.awayTeam.fullName)")
|
||||
@@ -325,21 +332,30 @@ final class GameMatcher {
|
||||
print("🎯 [GameMatcher] - result: multipleMatches (\(matches.count) games)")
|
||||
case .noMatches(let reason):
|
||||
print("🎯 [GameMatcher] - result: noMatches - \(reason)")
|
||||
}
|
||||
#endif
|
||||
|
||||
if case .noMatches = matchResult {
|
||||
// FALLBACK: Try scraping historical game data if we have stadium + date
|
||||
// Try all nearby stadiums (important for multi-sport venues like American Airlines Center)
|
||||
if let captureDate = metadata.captureDate, !stadiumMatches.isEmpty {
|
||||
#if DEBUG
|
||||
print("🎯 [GameMatcher] - Trying historical scraper fallback...")
|
||||
#endif
|
||||
|
||||
for stadiumMatch in stadiumMatches {
|
||||
let stadium = stadiumMatch.stadium
|
||||
#if DEBUG
|
||||
print("🎯 [GameMatcher] - Trying \(stadium.name) (\(stadium.sport.rawValue))...")
|
||||
#endif
|
||||
|
||||
if let scrapedGame = await HistoricalGameScraper.shared.scrapeGame(
|
||||
stadium: stadium,
|
||||
date: captureDate
|
||||
) {
|
||||
#if DEBUG
|
||||
print("🎯 [GameMatcher] - ✅ Scraper found: \(scrapedGame.awayTeam) @ \(scrapedGame.homeTeam)")
|
||||
#endif
|
||||
|
||||
// Convert ScrapedGame to a match result
|
||||
matchResult = .singleMatch(GameMatchCandidate(
|
||||
@@ -350,9 +366,11 @@ final class GameMatcher {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if case .noMatches = matchResult {
|
||||
print("🎯 [GameMatcher] - Scraper found no game at any stadium")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,13 +44,17 @@ actor HistoricalGameScraper {
|
||||
|
||||
// Check cache first
|
||||
if let cached = cache[cacheKey] {
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] Cache hit for \(stadium.name) on \(dateKey(date))")
|
||||
#endif
|
||||
return cached
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] ════════════════════════════════════════")
|
||||
print("🌐 [Scraper] Scraping game for \(stadium.name) on \(dateKey(date))")
|
||||
print("🌐 [Scraper] Sport: \(stadium.sport.rawValue)")
|
||||
#endif
|
||||
|
||||
let result: ScrapedGame?
|
||||
switch stadium.sport {
|
||||
@@ -63,12 +67,15 @@ actor HistoricalGameScraper {
|
||||
case .nfl:
|
||||
result = await scrapeFootballReference(stadium: stadium, date: date)
|
||||
case .mls, .wnba, .nwsl:
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] ⚠️ \(stadium.sport.rawValue) scraping not yet implemented")
|
||||
#endif
|
||||
result = nil
|
||||
}
|
||||
|
||||
cache[cacheKey] = result
|
||||
|
||||
#if DEBUG
|
||||
if let game = result {
|
||||
print("🌐 [Scraper] ✅ Found: \(game.awayTeam) @ \(game.homeTeam)")
|
||||
if let score = game.formattedScore {
|
||||
@@ -78,6 +85,7 @@ actor HistoricalGameScraper {
|
||||
print("🌐 [Scraper] ❌ No game found")
|
||||
}
|
||||
print("🌐 [Scraper] ════════════════════════════════════════")
|
||||
#endif
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -103,7 +111,9 @@ actor HistoricalGameScraper {
|
||||
}
|
||||
|
||||
private func scrapeFootballReference(stadium: Stadium, date: Date) async -> ScrapedGame? {
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] ⚠️ NFL scraping not yet implemented")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -115,7 +125,9 @@ actor HistoricalGameScraper {
|
||||
date: Date,
|
||||
sport: Sport
|
||||
) async -> ScrapedGame? {
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] Fetching: \(urlString)")
|
||||
#endif
|
||||
|
||||
guard let url = URL(string: urlString) else { return nil }
|
||||
|
||||
@@ -128,23 +140,33 @@ actor HistoricalGameScraper {
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200,
|
||||
let html = String(data: data, encoding: .utf8) else {
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] ❌ Failed to fetch page")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] Parsing HTML (\(data.count) bytes)")
|
||||
#endif
|
||||
|
||||
let targetTeams = await findTeamNamesForStadium(stadium)
|
||||
guard !targetTeams.isEmpty else {
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] ❌ No team mapping for stadium: \(stadium.name)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] Looking for home team: \(targetTeams.first ?? "unknown")")
|
||||
#endif
|
||||
|
||||
// Parse with SwiftSoup
|
||||
let doc = try SwiftSoup.parse(html)
|
||||
let gameSummaries = try doc.select("div.game_summary")
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] Found \(gameSummaries.count) games on this date")
|
||||
#endif
|
||||
|
||||
for game in gameSummaries {
|
||||
let rows = try game.select("table.teams tbody tr")
|
||||
@@ -159,7 +181,9 @@ actor HistoricalGameScraper {
|
||||
let awayScoreText = try awayRow.select("td.right").first()?.text() ?? ""
|
||||
let homeScoreText = try homeRow.select("td.right").first()?.text() ?? ""
|
||||
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] Game: \(awayTeam) @ \(homeTeam)")
|
||||
#endif
|
||||
|
||||
// Check if any of our target team names match the home team
|
||||
let isMatch = targetTeams.contains { targetName in
|
||||
@@ -181,10 +205,14 @@ actor HistoricalGameScraper {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] ⚠️ No game found for team: \(targetTeams.first ?? "unknown")")
|
||||
#endif
|
||||
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] ❌ Error: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -216,7 +244,9 @@ actor HistoricalGameScraper {
|
||||
|
||||
// Find the team that plays at this stadium using canonical ID
|
||||
guard let team = teams.first(where: { $0.stadiumId == stadium.id }) else {
|
||||
#if DEBUG
|
||||
print("🌐 [Scraper] No team found for stadium ID: \(stadium.id)")
|
||||
#endif
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
@@ -43,31 +43,43 @@ actor PhotoMetadataExtractor {
|
||||
/// Extract metadata from PHAsset (preferred method)
|
||||
/// Uses PHAsset's location and creationDate properties
|
||||
func extractMetadata(from asset: PHAsset) async -> PhotoMetadata {
|
||||
#if DEBUG
|
||||
print("📸 [PhotoMetadata] ════════════════════════════════════════")
|
||||
print("📸 [PhotoMetadata] Extracting from PHAsset: \(asset.localIdentifier)")
|
||||
print("📸 [PhotoMetadata] Asset mediaType: \(asset.mediaType.rawValue) (1=image, 2=video)")
|
||||
print("📸 [PhotoMetadata] Asset creationDate: \(asset.creationDate?.description ?? "nil")")
|
||||
print("📸 [PhotoMetadata] Asset modificationDate: \(asset.modificationDate?.description ?? "nil")")
|
||||
print("📸 [PhotoMetadata] Asset location: \(asset.location?.description ?? "nil")")
|
||||
#endif
|
||||
|
||||
// PHAsset provides location and date directly
|
||||
let coordinates: CLLocationCoordinate2D?
|
||||
if let location = asset.location {
|
||||
coordinates = location.coordinate
|
||||
#if DEBUG
|
||||
print("📸 [PhotoMetadata] ✅ Location found: \(coordinates!.latitude), \(coordinates!.longitude)")
|
||||
#endif
|
||||
} else {
|
||||
coordinates = nil
|
||||
#if DEBUG
|
||||
print("📸 [PhotoMetadata] ⚠️ No location in PHAsset, trying ImageIO fallback...")
|
||||
#endif
|
||||
|
||||
// Try ImageIO extraction as fallback
|
||||
if let imageData = await loadImageData(from: asset) {
|
||||
#if DEBUG
|
||||
print("📸 [PhotoMetadata] Loaded image data: \(imageData.count) bytes")
|
||||
#endif
|
||||
let fallbackMetadata = extractMetadata(from: imageData)
|
||||
if fallbackMetadata.hasValidLocation || fallbackMetadata.hasValidDate {
|
||||
#if DEBUG
|
||||
print("📸 [PhotoMetadata] ✅ ImageIO fallback found data - date: \(fallbackMetadata.captureDate?.description ?? "nil"), coords: \(fallbackMetadata.coordinates?.latitude ?? 0), \(fallbackMetadata.coordinates?.longitude ?? 0)")
|
||||
#endif
|
||||
return fallbackMetadata
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("📸 [PhotoMetadata] ⚠️ ImageIO fallback found no metadata either")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,8 +88,10 @@ actor PhotoMetadataExtractor {
|
||||
captureDate: asset.creationDate,
|
||||
coordinates: coordinates
|
||||
)
|
||||
#if DEBUG
|
||||
print("📸 [PhotoMetadata] Final result - hasDate: \(result.hasValidDate), hasLocation: \(result.hasValidLocation)")
|
||||
print("📸 [PhotoMetadata] ════════════════════════════════════════")
|
||||
#endif
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -96,18 +110,25 @@ actor PhotoMetadataExtractor {
|
||||
/// Extract metadata from raw image data using ImageIO
|
||||
/// Useful when PHAsset is not available
|
||||
func extractMetadata(from imageData: Data) -> PhotoMetadata {
|
||||
#if DEBUG
|
||||
print("📸 [ImageIO] Extracting from image data (\(imageData.count) bytes)")
|
||||
#endif
|
||||
|
||||
guard let source = CGImageSourceCreateWithData(imageData as CFData, nil) else {
|
||||
#if DEBUG
|
||||
print("📸 [ImageIO] ❌ Failed to create CGImageSource")
|
||||
#endif
|
||||
return .empty
|
||||
}
|
||||
|
||||
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else {
|
||||
#if DEBUG
|
||||
print("📸 [ImageIO] ❌ Failed to copy properties from source")
|
||||
#endif
|
||||
return .empty
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Debug: Print all available property dictionaries
|
||||
print("📸 [ImageIO] Available property keys: \(properties.keys.map { $0 as String })")
|
||||
|
||||
@@ -141,12 +162,15 @@ actor PhotoMetadataExtractor {
|
||||
} else {
|
||||
print("📸 [ImageIO] ⚠️ No TIFF dictionary found")
|
||||
}
|
||||
#endif
|
||||
|
||||
let captureDate = extractDate(from: properties)
|
||||
let coordinates = extractCoordinates(from: properties)
|
||||
|
||||
#if DEBUG
|
||||
print("📸 [ImageIO] Extracted date: \(captureDate?.description ?? "nil")")
|
||||
print("📸 [ImageIO] Extracted coordinates: \(coordinates?.latitude ?? 0), \(coordinates?.longitude ?? 0)")
|
||||
#endif
|
||||
|
||||
return PhotoMetadata(
|
||||
captureDate: captureDate,
|
||||
|
||||
@@ -48,6 +48,7 @@ final class SuggestedTripsGenerator {
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
private var generationTask: Task<[SuggestedTrip], Never>?
|
||||
|
||||
// MARK: - Grouped Trips
|
||||
|
||||
@@ -73,6 +74,8 @@ final class SuggestedTripsGenerator {
|
||||
func generateTrips() async {
|
||||
guard !isLoading else { return }
|
||||
|
||||
generationTask?.cancel()
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
suggestedTrips = []
|
||||
@@ -121,7 +124,7 @@ final class SuggestedTripsGenerator {
|
||||
let teams = dataProvider.teams
|
||||
|
||||
// Move heavy computation to background task
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
let task = Task.detached(priority: .userInitiated) {
|
||||
await Self.generateTripsInBackground(
|
||||
games: games,
|
||||
stadiums: stadiums,
|
||||
@@ -129,9 +132,10 @@ final class SuggestedTripsGenerator {
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
)
|
||||
}.value
|
||||
}
|
||||
generationTask = task
|
||||
|
||||
suggestedTrips = result
|
||||
suggestedTrips = await task.value
|
||||
} catch {
|
||||
self.error = "Failed to generate trips: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ final class SyncLogger: @unchecked Sendable {
|
||||
let line = "[\(timestamp)] \(message)\n"
|
||||
|
||||
// Also print to console
|
||||
#if DEBUG
|
||||
print(message)
|
||||
#endif
|
||||
|
||||
queue.async { [weak self] in
|
||||
self?.appendToFile(line)
|
||||
|
||||
@@ -73,22 +73,33 @@ final class StoreManager {
|
||||
products.first { $0.id == "com.88oakapps.SportsTime.pro.annual2" }
|
||||
}
|
||||
|
||||
// MARK: - Transaction Listener
|
||||
|
||||
nonisolated(unsafe) private var transactionListenerTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init() {}
|
||||
|
||||
deinit {
|
||||
transactionListenerTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Product Loading
|
||||
|
||||
func loadProducts() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
#if DEBUG
|
||||
print("[StoreManager] Loading products for IDs: \(Self.proProductIDs)")
|
||||
#endif
|
||||
|
||||
do {
|
||||
let fetchedProducts = try await Product.products(for: Self.proProductIDs)
|
||||
products = fetchedProducts
|
||||
|
||||
#if DEBUG
|
||||
print("[StoreManager] Loaded \(fetchedProducts.count) products:")
|
||||
for product in fetchedProducts {
|
||||
print("[StoreManager] - \(product.id): \(product.displayPrice) (\(product.displayName))")
|
||||
@@ -101,16 +112,21 @@ final class StoreManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Treat an empty fetch as a configuration issue so UI can show a useful fallback.
|
||||
if fetchedProducts.isEmpty {
|
||||
#if DEBUG
|
||||
print("[StoreManager] WARNING: No products returned — check Configuration.storekit")
|
||||
#endif
|
||||
error = .productNotFound
|
||||
}
|
||||
} catch {
|
||||
products = []
|
||||
self.error = .productNotFound
|
||||
#if DEBUG
|
||||
print("[StoreManager] ERROR loading products: \(error)")
|
||||
#endif
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
@@ -289,10 +305,11 @@ final class StoreManager {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// MARK: - Transaction Listener
|
||||
// MARK: - Transaction Listening
|
||||
|
||||
func listenForTransactions() -> Task<Void, Never> {
|
||||
Task.detached {
|
||||
func startListeningForTransactions() {
|
||||
transactionListenerTask?.cancel()
|
||||
transactionListenerTask = Task.detached {
|
||||
for await result in Transaction.updates {
|
||||
if case .verified(let transaction) = result {
|
||||
await transaction.finish()
|
||||
|
||||
@@ -198,6 +198,7 @@ struct StatPill: View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption)
|
||||
.accessibilityHidden(true)
|
||||
Text(value)
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
@@ -269,7 +269,9 @@ extension SportSelectorGrid where Content == SportToggleButton {
|
||||
|
||||
SportSelectorGrid { sport in
|
||||
SportActionButton(sport: sport) {
|
||||
#if DEBUG
|
||||
print("Selected \(sport.rawValue)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user