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:
Michael Simard
2025-12-07 00:40:31 -06:00
commit 1ade3b39ff
47 changed files with 4038 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
//
// WatchConnectivityClient.swift
// FantasyWatch Watch App
//
// Created by Claude Code
//
import ComposableArchitecture
import Foundation
import WatchConnectivity
@DependencyClient
struct WatchConnectivityClient {
var matchupUpdates: @Sendable () -> AsyncStream<(Matchup, RosterStatus, Date)>
var requestRefresh: @Sendable () async throws -> Void
}
extension WatchConnectivityClient: DependencyKey {
static let liveValue: WatchConnectivityClient = {
let manager = WatchConnectivityManager()
return Self(
matchupUpdates: {
manager.matchupUpdatesStream()
},
requestRefresh: {
try await manager.requestRefresh()
}
)
}()
static let testValue = Self(
matchupUpdates: {
AsyncStream { continuation in
continuation.yield((
Matchup(
week: 1,
status: "midevent",
userTeam: TeamScore(teamKey: "test.l.123.t.1", teamName: "Test Team", wins: 5, losses: 3, ties: 1),
opponentTeam: TeamScore(teamKey: "test.l.123.t.2", teamName: "Opponent", wins: 4, losses: 4, ties: 1),
categories: []
),
RosterStatus(activeCount: 10, benchedCount: 5, injuredReserve: 2),
Date()
))
}
},
requestRefresh: {}
)
}
extension DependencyValues {
var watchConnectivityClient: WatchConnectivityClient {
get { self[WatchConnectivityClient.self] }
set { self[WatchConnectivityClient.self] = newValue }
}
}
// MARK: - Watch Connectivity Manager
final class WatchConnectivityManager: NSObject, WCSessionDelegate {
private var session: WCSession?
private var updatesContinuation: AsyncStream<(Matchup, RosterStatus, Date)>.Continuation?
override init() {
super.init()
guard WCSession.isSupported() else { return }
session = WCSession.default
session?.delegate = self
session?.activate()
}
func matchupUpdatesStream() -> AsyncStream<(Matchup, RosterStatus, Date)> {
AsyncStream { continuation in
self.updatesContinuation = continuation
if let context = session?.receivedApplicationContext,
let data = context["payload"] as? Data,
let payload = try? JSONDecoder().decode(WatchPayload.self, from: data) {
continuation.yield((payload.matchup, payload.roster, payload.timestamp))
}
}
}
func requestRefresh() async throws {
guard let session = session, session.activationState == .activated else {
throw NetworkError.networkFailure(
NSError(domain: "WatchConnectivity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Watch session not active"])
)
}
let message = ["type": WatchMessage.refreshRequest.rawValue]
session.sendMessage(message, replyHandler: nil) { error in
print("Failed to send refresh request: \(error.localizedDescription)")
}
}
// MARK: - WCSessionDelegate
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("Watch session activation failed: \(error.localizedDescription)")
}
}
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
guard let data = applicationContext["payload"] as? Data,
let payload = try? JSONDecoder().decode(WatchPayload.self, from: data) else {
return
}
updatesContinuation?.yield((payload.matchup, payload.roster, payload.timestamp))
}
}