diff --git a/SportsTime/Core/Services/DeepLinkHandler.swift b/SportsTime/Core/Services/DeepLinkHandler.swift new file mode 100644 index 0000000..8eb36ed --- /dev/null +++ b/SportsTime/Core/Services/DeepLinkHandler.swift @@ -0,0 +1,146 @@ +// +// DeepLinkHandler.swift +// SportsTime +// +// Handles deep link URL routing for the app +// + +import Foundation +import SwiftUI + +// 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: String? { + 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 = 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 + } + } +} diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index aebf59b..2fa475e 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -81,7 +81,7 @@ struct BootstrappedContentView: View { @State private var bootstrapError: Error? @State private var hasCompletedInitialSync = false @State private var showOnboardingPaywall = false - @State private var pollShareCode: String? + @State private var deepLinkHandler = DeepLinkHandler.shared private var shouldShowOnboardingPaywall: Bool { !UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro @@ -103,11 +103,16 @@ struct BootstrappedContentView: View { OnboardingPaywallView(isPresented: $showOnboardingPaywall) .interactiveDismissDisabled() } - .sheet(item: $pollShareCode) { code in + .sheet(item: $deepLinkHandler.pendingPollShareCode) { code in NavigationStack { PollDetailView(shareCode: code) } } + .alert("Error", isPresented: .constant(deepLinkHandler.error != nil)) { + Button("OK") { deepLinkHandler.clearPending() } + } message: { + Text(deepLinkHandler.error?.localizedDescription ?? "") + } .onAppear { if shouldShowOnboardingPaywall { showOnboardingPaywall = true @@ -119,7 +124,7 @@ struct BootstrappedContentView: View { await performBootstrap() } .onOpenURL { url in - handleDeepLink(url) + deepLinkHandler.handleURL(url) } .onChange(of: scenePhase) { _, newPhase in switch newPhase { @@ -204,18 +209,6 @@ struct BootstrappedContentView: View { } } - // MARK: - Deep Link Handling - - private func handleDeepLink(_ url: URL) { - // Handle sportstime://poll/{code} deep links - guard url.scheme == "sportstime", - url.host == "poll", - let code = url.pathComponents.dropFirst().first, - code.count == 6 - else { return } - - pollShareCode = code.uppercased() - } } // MARK: - String Identifiable for Sheet diff --git a/SportsTimeTests/Mocks/MockData+Polls.swift b/SportsTimeTests/Mocks/MockData+Polls.swift new file mode 100644 index 0000000..8a8718f --- /dev/null +++ b/SportsTimeTests/Mocks/MockData+Polls.swift @@ -0,0 +1,130 @@ +// +// MockData+Polls.swift +// SportsTimeTests +// +// Mock data extensions for poll-related tests +// + +import Foundation +@testable import SportsTime + +// MARK: - Trip Mock + +extension Trip { + /// Creates a mock trip for testing + static func mock( + id: UUID = UUID(), + name: String = "Test Trip", + cities: [String] = ["Boston", "New York"], + startDate: Date = Date(), + games: [String] = [] + ) -> Trip { + let stops = cities.enumerated().map { index, city in + TripStop.mock( + stopNumber: index + 1, + city: city, + arrivalDate: startDate.addingTimeInterval(Double(index) * 86400), + departureDate: startDate.addingTimeInterval(Double(index + 1) * 86400), + games: games + ) + } + + return Trip( + id: id, + name: name, + preferences: TripPreferences( + planningMode: .dateRange, + sports: [.mlb], + startDate: startDate, + endDate: startDate.addingTimeInterval(86400 * Double(cities.count)) + ), + stops: stops + ) + } +} + +// MARK: - TripStop Mock + +extension TripStop { + /// Creates a mock trip stop for testing + static func mock( + stopNumber: Int = 1, + city: String = "Boston", + state: String = "MA", + arrivalDate: Date = Date(), + departureDate: Date? = nil, + games: [String] = [] + ) -> TripStop { + TripStop( + stopNumber: stopNumber, + city: city, + state: state, + arrivalDate: arrivalDate, + departureDate: departureDate ?? arrivalDate.addingTimeInterval(86400), + games: games + ) + } +} + +// MARK: - TripPoll Mock + +extension TripPoll { + /// Creates a mock poll for testing + static func mock( + id: UUID = UUID(), + title: String = "Test Poll", + ownerId: String = "mockOwner", + shareCode: String? = nil, + tripCount: Int = 2, + trips: [Trip]? = nil + ) -> TripPoll { + let tripSnapshots = trips ?? (0.. PollVote { + PollVote( + id: id, + pollId: pollId, + odg: odg, + rankings: rankings + ) + } +} + +// MARK: - PollResults Mock + +extension PollResults { + /// Creates mock results for testing + static func mock( + poll: TripPoll? = nil, + votes: [PollVote]? = nil + ) -> PollResults { + let testPoll = poll ?? TripPoll.mock() + let testVotes = votes ?? [ + PollVote.mock(pollId: testPoll.id, odg: "voter1", rankings: [0, 1]), + PollVote.mock(pollId: testPoll.id, odg: "voter2", rankings: [1, 0]) + ] + + return PollResults(poll: testPoll, votes: testVotes) + } +}