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:
146
SportsTime/Core/Services/DeepLinkHandler.swift
Normal file
146
SportsTime/Core/Services/DeepLinkHandler.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ struct BootstrappedContentView: View {
|
|||||||
@State private var bootstrapError: Error?
|
@State private var bootstrapError: Error?
|
||||||
@State private var hasCompletedInitialSync = false
|
@State private var hasCompletedInitialSync = false
|
||||||
@State private var showOnboardingPaywall = false
|
@State private var showOnboardingPaywall = false
|
||||||
@State private var pollShareCode: String?
|
@State private var deepLinkHandler = DeepLinkHandler.shared
|
||||||
|
|
||||||
private var shouldShowOnboardingPaywall: Bool {
|
private var shouldShowOnboardingPaywall: Bool {
|
||||||
!UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro
|
!UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro
|
||||||
@@ -103,11 +103,16 @@ struct BootstrappedContentView: View {
|
|||||||
OnboardingPaywallView(isPresented: $showOnboardingPaywall)
|
OnboardingPaywallView(isPresented: $showOnboardingPaywall)
|
||||||
.interactiveDismissDisabled()
|
.interactiveDismissDisabled()
|
||||||
}
|
}
|
||||||
.sheet(item: $pollShareCode) { code in
|
.sheet(item: $deepLinkHandler.pendingPollShareCode) { code in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
PollDetailView(shareCode: code)
|
PollDetailView(shareCode: code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert("Error", isPresented: .constant(deepLinkHandler.error != nil)) {
|
||||||
|
Button("OK") { deepLinkHandler.clearPending() }
|
||||||
|
} message: {
|
||||||
|
Text(deepLinkHandler.error?.localizedDescription ?? "")
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if shouldShowOnboardingPaywall {
|
if shouldShowOnboardingPaywall {
|
||||||
showOnboardingPaywall = true
|
showOnboardingPaywall = true
|
||||||
@@ -119,7 +124,7 @@ struct BootstrappedContentView: View {
|
|||||||
await performBootstrap()
|
await performBootstrap()
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
handleDeepLink(url)
|
deepLinkHandler.handleURL(url)
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
switch newPhase {
|
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
|
// MARK: - String Identifiable for Sheet
|
||||||
|
|||||||
130
SportsTimeTests/Mocks/MockData+Polls.swift
Normal file
130
SportsTimeTests/Mocks/MockData+Polls.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user