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 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
|
||||
|
||||
Reference in New Issue
Block a user