feat(polls): add DeepLinkHandler and test mock helpers

- Extract deep link handling into dedicated DeepLinkHandler service
- Add MockData+Polls.swift with reusable test mocks for Trip, TripStop,
  TripPoll, PollVote, and PollResults
- Update SportsTimeApp to use DeepLinkHandler.shared
- Add error alert for deep link failures

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-13 22:03:22 -06:00
parent 136c356384
commit 2f9546f792
3 changed files with 284 additions and 15 deletions

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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..<tripCount).map { index in
Trip.mock(name: "Trip \(index + 1)", cities: ["City\(index)A", "City\(index)B"])
}
return TripPoll(
id: id,
title: title,
ownerId: ownerId,
shareCode: shareCode ?? TripPoll.generateShareCode(),
tripSnapshots: tripSnapshots
)
}
}
// MARK: - PollVote Mock
extension PollVote {
/// Creates a mock vote for testing
static func mock(
id: UUID = UUID(),
pollId: UUID = UUID(),
odg: String = "mockVoter",
rankings: [Int] = [0, 1]
) -> 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)
}
}