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,84 @@
//
// CategoryBreakdownView.swift
// FantasyWatch Watch App
//
// Created by Claude Code
//
import SwiftUI
struct CategoryBreakdownView: View {
let categories: [CategoryScore]
var body: some View {
List(categories) { category in
HStack(spacing: 8) {
Text(category.name)
.font(.caption)
.fontWeight(.semibold)
.frame(width: 40, alignment: .leading)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(category.userValue)
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(colorForComparison(category.comparison))
Text(category.opponentValue)
.font(.caption2)
.foregroundColor(.secondary)
}
comparisonIcon(category.comparison)
.font(.caption2)
.frame(width: 20)
}
}
.navigationTitle("Categories")
.navigationBarTitleDisplayMode(.inline)
}
private func colorForComparison(_ comparison: CategoryScore.ComparisonResult) -> Color {
switch comparison {
case .winning:
return .green
case .losing:
return .red
case .tied:
return .orange
}
}
@ViewBuilder
private func comparisonIcon(_ comparison: CategoryScore.ComparisonResult) -> some View {
switch comparison {
case .winning:
Image(systemName: "arrow.up")
.foregroundColor(.green)
case .losing:
Image(systemName: "arrow.down")
.foregroundColor(.red)
case .tied:
Image(systemName: "equal")
.foregroundColor(.orange)
}
}
}
#Preview {
NavigationStack {
CategoryBreakdownView(
categories: [
CategoryScore(statID: "1", name: "G", userValue: "10", opponentValue: "8"),
CategoryScore(statID: "2", name: "A", userValue: "15", opponentValue: "18"),
CategoryScore(statID: "3", name: "PPP", userValue: "5", opponentValue: "5"),
CategoryScore(statID: "4", name: "SOG", userValue: "200", opponentValue: "195"),
CategoryScore(statID: "5", name: "W", userValue: "3", opponentValue: "4"),
CategoryScore(statID: "6", name: "GAA", userValue: "2.45", opponentValue: "2.80"),
CategoryScore(statID: "7", name: "SV%", userValue: "0.915", opponentValue: "0.905")
]
)
}
}

View File

@@ -0,0 +1,182 @@
//
// MatchupView.swift
// FantasyWatch Watch App
//
// Created by Claude Code
//
import ComposableArchitecture
import SwiftUI
struct MatchupView: View {
let store: StoreOf<WatchMatchupFeature>
var body: some View {
WithPerceptionTracking {
NavigationStack {
VStack(spacing: 10) {
if let matchup = store.matchup {
matchupContent(matchup: matchup)
} else {
noDataView
}
}
.navigationTitle("Fantasy Hockey")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
store.send(.refreshTapped)
} label: {
if store.isRefreshing {
ProgressView()
} else {
Image(systemName: "arrow.clockwise")
}
}
.disabled(store.isRefreshing)
}
}
.onAppear {
store.send(.onAppear)
}
}
}
}
@ViewBuilder
private func matchupContent(matchup: Matchup) -> some View {
ScrollView {
VStack(spacing: 12) {
// Week indicator
Text("Week \(matchup.week)")
.font(.caption)
.foregroundColor(.secondary)
// Score comparison
VStack(spacing: 8) {
HStack {
Text("You")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Text("\(matchup.userTeam.wins)-\(matchup.userTeam.losses)-\(matchup.userTeam.ties)")
.font(.title3)
.fontWeight(.bold)
.foregroundColor(.blue)
}
HStack {
Text("Opp")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Text("\(matchup.opponentTeam.wins)-\(matchup.opponentTeam.losses)-\(matchup.opponentTeam.ties)")
.font(.title3)
.fontWeight(.semibold)
}
}
.padding(.vertical, 8)
// Category summary
HStack(spacing: 8) {
categoryBadge(
title: "W",
count: matchup.categories.filter { $0.comparison == .winning }.count,
color: .green
)
categoryBadge(
title: "T",
count: matchup.categories.filter { $0.comparison == .tied }.count,
color: .orange
)
categoryBadge(
title: "L",
count: matchup.categories.filter { $0.comparison == .losing }.count,
color: .red
)
}
// Navigation links
NavigationLink {
CategoryBreakdownView(categories: matchup.categories)
} label: {
Label("Categories", systemImage: "list.bullet")
.font(.caption)
}
if let roster = store.roster {
NavigationLink {
RosterStatusView(roster: roster)
} label: {
Label("Roster", systemImage: "person.3")
.font(.caption)
}
}
if let lastUpdate = store.lastUpdate {
Text("Updated \(lastUpdate.formatted(date: .omitted, time: .shortened))")
.font(.caption2)
.foregroundColor(.secondary)
.padding(.top, 4)
}
}
.padding()
}
}
private var noDataView: some View {
VStack(spacing: 10) {
Image(systemName: "hockey.puck")
.font(.title)
.foregroundColor(.secondary)
Text("No data")
.font(.caption)
.foregroundColor(.secondary)
Text("Open iPhone app to sync")
.font(.caption2)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
private func categoryBadge(title: String, count: Int, color: Color) -> some View {
VStack(spacing: 2) {
Text("\(count)")
.font(.headline)
.fontWeight(.bold)
.foregroundColor(color)
Text(title)
.font(.caption2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(color.opacity(0.15))
.cornerRadius(6)
}
}
#Preview {
MatchupView(
store: Store(
initialState: WatchMatchupFeature.State(
matchup: Matchup(
week: 10,
status: "midevent",
userTeam: TeamScore(teamKey: "test.l.123.t.1", teamName: "My Team", wins: 5, losses: 3, ties: 1),
opponentTeam: TeamScore(teamKey: "test.l.123.t.2", teamName: "Opponent", wins: 4, losses: 4, ties: 1),
categories: [
CategoryScore(statID: "1", name: "G", userValue: "10", opponentValue: "8"),
CategoryScore(statID: "2", name: "A", userValue: "15", opponentValue: "18")
]
),
roster: RosterStatus(activeCount: 10, benchedCount: 5, injuredReserve: 2),
lastUpdate: Date()
)
) {
WatchMatchupFeature()
}
)
}

View File

@@ -0,0 +1,64 @@
//
// RosterStatusView.swift
// FantasyWatch Watch App
//
// Created by Claude Code
//
import SwiftUI
struct RosterStatusView: View {
let roster: RosterStatus
var body: some View {
List {
HStack {
Text("Active")
.font(.caption)
Spacer()
Text("\(roster.activeCount)")
.font(.body)
.fontWeight(.semibold)
.foregroundColor(.green)
}
HStack {
Text("Benched")
.font(.caption)
Spacer()
Text("\(roster.benchedCount)")
.font(.body)
.fontWeight(.semibold)
.foregroundColor(.secondary)
}
HStack {
Text("Injured Reserve")
.font(.caption)
Spacer()
Text("\(roster.injuredReserve)")
.font(.body)
.fontWeight(.semibold)
.foregroundColor(roster.injuredReserve > 0 ? .red : .secondary)
}
HStack {
Text("Total")
.font(.caption)
.fontWeight(.semibold)
Spacer()
Text("\(roster.totalPlayers)")
.font(.body)
.fontWeight(.bold)
}
}
.navigationTitle("Roster")
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
NavigationStack {
RosterStatusView(roster: RosterStatus(activeCount: 10, benchedCount: 5, injuredReserve: 2))
}
}

View File

@@ -0,0 +1,63 @@
//
// WatchMatchupFeature.swift
// FantasyWatch Watch App
//
// Created by Claude Code
//
import ComposableArchitecture
import Foundation
@Reducer
struct WatchMatchupFeature {
@ObservableState
struct State: Equatable {
var matchup: Matchup?
var roster: RosterStatus?
var lastUpdate: Date?
var isRefreshing = false
}
enum Action {
case onAppear
case receivedMatchupData(Matchup, RosterStatus, Date)
case refreshTapped
case refreshComplete
}
@Dependency(\.watchConnectivityClient) var watchConnectivityClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
return .run { send in
for await (matchup, roster, timestamp) in watchConnectivityClient.matchupUpdates() {
await send(.receivedMatchupData(matchup, roster, timestamp))
}
}
case let .receivedMatchupData(matchup, roster, timestamp):
state.matchup = matchup
state.roster = roster
state.lastUpdate = timestamp
state.isRefreshing = false
return .none
case .refreshTapped:
state.isRefreshing = true
return .run { send in
try await watchConnectivityClient.requestRefresh()
try await Task.sleep(for: .seconds(2))
await send(.refreshComplete)
} catch: { error, send in
await send(.refreshComplete)
}
case .refreshComplete:
state.isRefreshing = false
return .none
}
}
}
}