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:
@@ -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")
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user