Initial implementation of Fantasy Hockey watchOS app
Implemented complete TCA architecture for iOS and watchOS targets: - Authentication flow (Sign in with Apple + Yahoo OAuth) - OAuth token management with iCloud Key-Value Storage - Yahoo Fantasy Sports API client with async/await - Watch Connectivity for iPhone ↔ Watch data sync - Complete UI for both iPhone and Watch platforms Core features: - Matchup score display - Category breakdown with win/loss/tie indicators - Roster status tracking - Manual refresh functionality - Persistent data caching on Watch Technical stack: - The Composable Architecture for state management - Swift Concurrency (async/await, actors) - WatchConnectivity framework - Sign in with Apple - OAuth 2.0 authentication flow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
110
FantasyWatch/Shared/Networking/OAuth/OAuthManager.swift
Normal file
110
FantasyWatch/Shared/Networking/OAuth/OAuthManager.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// OAuthManager.swift
|
||||
// FantasyWatch
|
||||
//
|
||||
// Created by Claude Code
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
actor OAuthManager {
|
||||
private let tokenStorage: OAuthTokenStorage
|
||||
private let clientID: String
|
||||
private let clientSecret: String
|
||||
private var refreshTask: Task<TokenPair, Error>?
|
||||
private var currentUserID: String?
|
||||
|
||||
init(
|
||||
tokenStorage: OAuthTokenStorage,
|
||||
clientID: String,
|
||||
clientSecret: String
|
||||
) {
|
||||
self.tokenStorage = tokenStorage
|
||||
self.clientID = clientID
|
||||
self.clientSecret = clientSecret
|
||||
}
|
||||
|
||||
func setCurrentUser(_ userID: String) {
|
||||
self.currentUserID = userID
|
||||
}
|
||||
|
||||
func validToken() async throws -> String {
|
||||
guard let userID = currentUserID else {
|
||||
throw OAuthError.authorizationFailed
|
||||
}
|
||||
|
||||
if await tokenStorage.isTokenValid(for: userID) {
|
||||
guard let token = await tokenStorage.getAccessToken(for: userID) else {
|
||||
throw OAuthError.tokenExpired
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
let refreshedTokenPair = try await refreshToken()
|
||||
return refreshedTokenPair.accessToken
|
||||
}
|
||||
|
||||
func refreshToken() async throws -> TokenPair {
|
||||
if let existingTask = refreshTask {
|
||||
return try await existingTask.value
|
||||
}
|
||||
|
||||
guard let userID = currentUserID else {
|
||||
throw OAuthError.authorizationFailed
|
||||
}
|
||||
|
||||
guard let refreshToken = await tokenStorage.getRefreshToken(for: userID) else {
|
||||
throw OAuthError.noRefreshToken
|
||||
}
|
||||
|
||||
let task = Task<TokenPair, Error> {
|
||||
let url = URL(string: "https://api.login.yahoo.com/oauth2/get_token")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let bodyParams = [
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshToken,
|
||||
"client_id": clientID,
|
||||
"client_secret": clientSecret
|
||||
]
|
||||
|
||||
let bodyString = bodyParams
|
||||
.map { "\($0.key)=\($0.value)" }
|
||||
.joined(separator: "&")
|
||||
|
||||
request.httpBody = bodyString.data(using: .utf8)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200...299).contains(httpResponse.statusCode) else {
|
||||
throw OAuthError.invalidResponse
|
||||
}
|
||||
|
||||
let tokenResponse = try JSONDecoder().decode(OAuthTokenResponse.self, from: data)
|
||||
let tokenPair = tokenResponse.toTokenPair()
|
||||
|
||||
await tokenStorage.saveTokenPair(tokenPair, for: userID)
|
||||
|
||||
return tokenPair
|
||||
}
|
||||
|
||||
refreshTask = task
|
||||
|
||||
defer { refreshTask = nil }
|
||||
|
||||
return try await task.value
|
||||
}
|
||||
|
||||
func saveTokenPair(_ tokenPair: TokenPair) async {
|
||||
guard let userID = currentUserID else { return }
|
||||
await tokenStorage.saveTokenPair(tokenPair, for: userID)
|
||||
}
|
||||
|
||||
func clearTokens() async {
|
||||
guard let userID = currentUserID else { return }
|
||||
await tokenStorage.clearTokens(for: userID)
|
||||
}
|
||||
}
|
||||
43
FantasyWatch/Shared/Networking/OAuth/OAuthModels.swift
Normal file
43
FantasyWatch/Shared/Networking/OAuth/OAuthModels.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// OAuthModels.swift
|
||||
// FantasyWatch
|
||||
//
|
||||
// Created by Claude Code
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TokenPair: Codable, Equatable, Sendable {
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
let expiresIn: Int
|
||||
let tokenType: String
|
||||
|
||||
var expiryDate: Date {
|
||||
Date().addingTimeInterval(TimeInterval(expiresIn))
|
||||
}
|
||||
}
|
||||
|
||||
struct OAuthTokenResponse: Codable {
|
||||
let access_token: String
|
||||
let refresh_token: String
|
||||
let expires_in: Int
|
||||
let token_type: String
|
||||
|
||||
func toTokenPair() -> TokenPair {
|
||||
TokenPair(
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
expiresIn: expires_in,
|
||||
tokenType: token_type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum OAuthError: Error, Equatable {
|
||||
case noRefreshToken
|
||||
case tokenExpired
|
||||
case authorizationFailed
|
||||
case networkError(String)
|
||||
case invalidResponse
|
||||
}
|
||||
59
FantasyWatch/Shared/Networking/OAuth/OAuthTokenStorage.swift
Normal file
59
FantasyWatch/Shared/Networking/OAuth/OAuthTokenStorage.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// OAuthTokenStorage.swift
|
||||
// FantasyWatch
|
||||
//
|
||||
// Created by Claude Code
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
actor OAuthTokenStorage {
|
||||
private let ubiquitousStore = NSUbiquitousKeyValueStore.default
|
||||
|
||||
private func accessTokenKey(for userID: String) -> String {
|
||||
"com.fantasyhockey.user.\(userID).yahoo.accessToken"
|
||||
}
|
||||
|
||||
private func refreshTokenKey(for userID: String) -> String {
|
||||
"com.fantasyhockey.user.\(userID).yahoo.refreshToken"
|
||||
}
|
||||
|
||||
private func tokenExpiryKey(for userID: String) -> String {
|
||||
"com.fantasyhockey.user.\(userID).yahoo.tokenExpiry"
|
||||
}
|
||||
|
||||
func saveTokenPair(_ tokenPair: TokenPair, for userID: String) {
|
||||
ubiquitousStore.set(tokenPair.accessToken, forKey: accessTokenKey(for: userID))
|
||||
ubiquitousStore.set(tokenPair.refreshToken, forKey: refreshTokenKey(for: userID))
|
||||
ubiquitousStore.set(tokenPair.expiryDate.timeIntervalSince1970, forKey: tokenExpiryKey(for: userID))
|
||||
ubiquitousStore.synchronize()
|
||||
}
|
||||
|
||||
func getAccessToken(for userID: String) -> String? {
|
||||
ubiquitousStore.string(forKey: accessTokenKey(for: userID))
|
||||
}
|
||||
|
||||
func getRefreshToken(for userID: String) -> String? {
|
||||
ubiquitousStore.string(forKey: refreshTokenKey(for: userID))
|
||||
}
|
||||
|
||||
func getTokenExpiry(for userID: String) -> Date? {
|
||||
let timestamp = ubiquitousStore.double(forKey: tokenExpiryKey(for: userID))
|
||||
guard timestamp > 0 else { return nil }
|
||||
return Date(timeIntervalSince1970: timestamp)
|
||||
}
|
||||
|
||||
func isTokenValid(for userID: String) -> Bool {
|
||||
guard let expiryDate = getTokenExpiry(for: userID) else {
|
||||
return false
|
||||
}
|
||||
return expiryDate > Date().addingTimeInterval(60)
|
||||
}
|
||||
|
||||
func clearTokens(for userID: String) {
|
||||
ubiquitousStore.removeObject(forKey: accessTokenKey(for: userID))
|
||||
ubiquitousStore.removeObject(forKey: refreshTokenKey(for: userID))
|
||||
ubiquitousStore.removeObject(forKey: tokenExpiryKey(for: userID))
|
||||
ubiquitousStore.synchronize()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user