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>
154 lines
4.0 KiB
Swift
154 lines
4.0 KiB
Swift
//
|
|
// DeepLinkHandler.swift
|
|
// SportsTime
|
|
//
|
|
// Handles deep link URL routing for the app
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
// MARK: - Identifiable Share Code
|
|
|
|
struct IdentifiableShareCode: Identifiable {
|
|
let id: String
|
|
var value: String { id }
|
|
}
|
|
|
|
// MARK: - Deep Link Handler
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class DeepLinkHandler {
|
|
static let shared = DeepLinkHandler()
|
|
|
|
// MARK: - State
|
|
|
|
/// The pending poll share code from a deep link
|
|
/// Setting to nil clears the pending state
|
|
var pendingPollShareCode: IdentifiableShareCode? {
|
|
didSet {
|
|
if pendingPollShareCode == nil {
|
|
pendingPoll = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The loaded poll from a deep link (if successfully fetched)
|
|
private(set) var pendingPoll: TripPoll?
|
|
|
|
/// Whether the handler is currently loading a poll
|
|
private(set) var isLoading = false
|
|
|
|
/// Any error that occurred while handling a deep link
|
|
var error: DeepLinkError?
|
|
|
|
private init() {}
|
|
|
|
// MARK: - URL Handling
|
|
|
|
/// Handles an incoming URL and routes to the appropriate destination
|
|
/// - Parameter url: The URL to handle
|
|
/// - Returns: The parsed deep link destination, if valid
|
|
@discardableResult
|
|
func handleURL(_ url: URL) -> DeepLinkDestination? {
|
|
guard let destination = parseURL(url) else { return nil }
|
|
|
|
switch destination {
|
|
case .poll(let shareCode):
|
|
pendingPollShareCode = IdentifiableShareCode(id: shareCode)
|
|
}
|
|
|
|
return destination
|
|
}
|
|
|
|
/// Parses a URL into a deep link destination
|
|
/// - Parameter url: The URL to parse
|
|
/// - Returns: The parsed destination, or nil if invalid
|
|
func parseURL(_ url: URL) -> DeepLinkDestination? {
|
|
// Validate scheme
|
|
guard url.scheme == "sportstime" else { return nil }
|
|
|
|
switch url.host {
|
|
case "poll":
|
|
// sportstime://poll/{code}
|
|
guard let code = url.pathComponents.dropFirst().first,
|
|
code.count == 6 else { return nil }
|
|
return .poll(shareCode: code.uppercased())
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Loads a poll by share code from CloudKit
|
|
/// - Parameter shareCode: The 6-character share code
|
|
func loadPoll(shareCode: String) async {
|
|
isLoading = true
|
|
error = nil
|
|
|
|
do {
|
|
let poll = try await PollService.shared.fetchPoll(byShareCode: shareCode)
|
|
pendingPoll = poll
|
|
} catch let pollError as PollError {
|
|
switch pollError {
|
|
case .pollNotFound:
|
|
error = .pollNotFound
|
|
case .networkUnavailable:
|
|
error = .networkUnavailable
|
|
default:
|
|
error = .loadFailed(pollError)
|
|
}
|
|
} catch {
|
|
self.error = .loadFailed(error)
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
/// Clears any pending deep link state
|
|
func clearPending() {
|
|
pendingPollShareCode = nil
|
|
pendingPoll = nil
|
|
error = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Deep Link Destination
|
|
|
|
enum DeepLinkDestination: Equatable {
|
|
case poll(shareCode: String)
|
|
}
|
|
|
|
// MARK: - Deep Link Error
|
|
|
|
enum DeepLinkError: LocalizedError, Equatable {
|
|
case pollNotFound
|
|
case networkUnavailable
|
|
case loadFailed(Error)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .pollNotFound:
|
|
return "This poll no longer exists or the code is invalid."
|
|
case .networkUnavailable:
|
|
return "Unable to connect. Please check your internet connection."
|
|
case .loadFailed(let error):
|
|
return "Failed to load: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
static func == (lhs: DeepLinkError, rhs: DeepLinkError) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.pollNotFound, .pollNotFound):
|
|
return true
|
|
case (.networkUnavailable, .networkUnavailable):
|
|
return true
|
|
case (.loadFailed, .loadFailed):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|