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

72
FantasyWatch/.gitignore vendored Normal file
View File

@@ -0,0 +1,72 @@
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## Compatibility with Xcode 8 and earlier
*.xcscmblueprint
*.xccheckout
## Compatibility with Xcode 3 and earlier
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
## Build generated
build/
DerivedData/
## Various settings
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata/
## Other
*.moved-aside
*.xccheckout
*.xcscmblueprint
## Obj-C/Swift specific
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
.build/
# CocoaPods
Pods/
# Carthage
Carthage/Build/
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Code Injection
iOSInjectionProject/
# CRITICAL: OAuth Credentials
Config.xcconfig

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

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))
}
}

View File

@@ -0,0 +1,24 @@
//
// ContentView.swift
// FantasyWatch Watch App Watch App
//
// Created by Michael Simard on 12/6/25.
//
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
#Preview {
ContentView()
}

View File

@@ -0,0 +1,22 @@
//
// FantasyWatch_Watch_AppApp.swift
// FantasyWatch Watch App Watch App
//
// Created by Michael Simard on 12/6/25.
//
import ComposableArchitecture
import SwiftUI
@main
struct FantasyWatch_Watch_App_Watch_AppApp: App {
let store = Store(initialState: WatchMatchupFeature.State()) {
WatchMatchupFeature()
}
var body: some Scene {
WindowGroup {
MatchupView(store: store)
}
}
}

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
}
}
}
}

View File

@@ -0,0 +1,790 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
ED28A8262EE5494A007F1AD7 /* FantasyWatch Watch App Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = ED28A8252EE5494A007F1AD7 /* FantasyWatch Watch App Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
ED28A8392EE54D1B007F1AD7 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = ED28A8382EE54D1B007F1AD7 /* ComposableArchitecture */; };
ED28A83B2EE54D2C007F1AD7 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = ED28A83A2EE54D2C007F1AD7 /* ComposableArchitecture */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
ED28A8272EE5494A007F1AD7 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = ED28A8082EE548FC007F1AD7 /* Project object */;
proxyType = 1;
remoteGlobalIDString = ED28A8242EE5494A007F1AD7;
remoteInfo = "FantasyWatch Watch App Watch App";
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
ED28A8332EE54953007F1AD7 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
ED28A8262EE5494A007F1AD7 /* FantasyWatch Watch App Watch App.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
ED28A8102EE548FC007F1AD7 /* FantasyWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FantasyWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
ED28A8202EE5494A007F1AD7 /* FantasyWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FantasyWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
ED28A8252EE5494A007F1AD7 /* FantasyWatch Watch App Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FantasyWatch Watch App Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
ED28A83E2EE54EF3007F1AD7 /* Exceptions for "FantasyWatch" folder in "FantasyWatch" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = ED28A80F2EE548FC007F1AD7 /* FantasyWatch */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
ED28A8122EE548FC007F1AD7 /* FantasyWatch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
ED28A83E2EE54EF3007F1AD7 /* Exceptions for "FantasyWatch" folder in "FantasyWatch" target */,
);
path = FantasyWatch;
sourceTree = "<group>";
};
ED28A8292EE5494A007F1AD7 /* FantasyWatch Watch App Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "FantasyWatch Watch App Watch App";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
ED28A80D2EE548FC007F1AD7 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
ED28A83B2EE54D2C007F1AD7 /* ComposableArchitecture in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
ED28A8222EE5494A007F1AD7 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
ED28A8392EE54D1B007F1AD7 /* ComposableArchitecture in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
ED28A8072EE548FC007F1AD7 = {
isa = PBXGroup;
children = (
ED28A8122EE548FC007F1AD7 /* FantasyWatch */,
ED28A8292EE5494A007F1AD7 /* FantasyWatch Watch App Watch App */,
ED28A8112EE548FC007F1AD7 /* Products */,
);
sourceTree = "<group>";
};
ED28A8112EE548FC007F1AD7 /* Products */ = {
isa = PBXGroup;
children = (
ED28A8102EE548FC007F1AD7 /* FantasyWatch.app */,
ED28A8202EE5494A007F1AD7 /* FantasyWatch Watch App.app */,
ED28A8252EE5494A007F1AD7 /* FantasyWatch Watch App Watch App.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
ED28A80F2EE548FC007F1AD7 /* FantasyWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = ED28A81B2EE548FE007F1AD7 /* Build configuration list for PBXNativeTarget "FantasyWatch" */;
buildPhases = (
ED28A80C2EE548FC007F1AD7 /* Sources */,
ED28A80D2EE548FC007F1AD7 /* Frameworks */,
ED28A80E2EE548FC007F1AD7 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
ED28A8122EE548FC007F1AD7 /* FantasyWatch */,
);
name = FantasyWatch;
packageProductDependencies = (
ED28A83A2EE54D2C007F1AD7 /* ComposableArchitecture */,
);
productName = FantasyWatch;
productReference = ED28A8102EE548FC007F1AD7 /* FantasyWatch.app */;
productType = "com.apple.product-type.application";
};
ED28A81F2EE5494A007F1AD7 /* FantasyWatch Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = ED28A8342EE54953007F1AD7 /* Build configuration list for PBXNativeTarget "FantasyWatch Watch App" */;
buildPhases = (
ED28A81E2EE5494A007F1AD7 /* Resources */,
ED28A8332EE54953007F1AD7 /* Embed Watch Content */,
);
buildRules = (
);
dependencies = (
ED28A8282EE5494A007F1AD7 /* PBXTargetDependency */,
);
name = "FantasyWatch Watch App";
packageProductDependencies = (
);
productName = "FantasyWatch Watch App";
productReference = ED28A8202EE5494A007F1AD7 /* FantasyWatch Watch App.app */;
productType = "com.apple.product-type.application.watchapp2-container";
};
ED28A8242EE5494A007F1AD7 /* FantasyWatch Watch App Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = ED28A8302EE54953007F1AD7 /* Build configuration list for PBXNativeTarget "FantasyWatch Watch App Watch App" */;
buildPhases = (
ED28A8212EE5494A007F1AD7 /* Sources */,
ED28A8222EE5494A007F1AD7 /* Frameworks */,
ED28A8232EE5494A007F1AD7 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
ED28A8292EE5494A007F1AD7 /* FantasyWatch Watch App Watch App */,
);
name = "FantasyWatch Watch App Watch App";
packageProductDependencies = (
ED28A8382EE54D1B007F1AD7 /* ComposableArchitecture */,
);
productName = "FantasyWatch Watch App Watch App";
productReference = ED28A8252EE5494A007F1AD7 /* FantasyWatch Watch App Watch App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
ED28A8082EE548FC007F1AD7 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
ED28A80F2EE548FC007F1AD7 = {
CreatedOnToolsVersion = 26.0;
};
ED28A81F2EE5494A007F1AD7 = {
CreatedOnToolsVersion = 26.0;
};
ED28A8242EE5494A007F1AD7 = {
CreatedOnToolsVersion = 26.0;
};
};
};
buildConfigurationList = ED28A80B2EE548FC007F1AD7 /* Build configuration list for PBXProject "FantasyWatch" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = ED28A8072EE548FC007F1AD7;
minimizedProjectReferenceProxies = 1;
packageReferences = (
ED28A8372EE54D1B007F1AD7 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = ED28A8112EE548FC007F1AD7 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
ED28A80F2EE548FC007F1AD7 /* FantasyWatch */,
ED28A81F2EE5494A007F1AD7 /* FantasyWatch Watch App */,
ED28A8242EE5494A007F1AD7 /* FantasyWatch Watch App Watch App */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
ED28A80E2EE548FC007F1AD7 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
ED28A81E2EE5494A007F1AD7 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
ED28A8232EE5494A007F1AD7 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
ED28A80C2EE548FC007F1AD7 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
ED28A8212EE5494A007F1AD7 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
ED28A8282EE5494A007F1AD7 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = ED28A8242EE5494A007F1AD7 /* FantasyWatch Watch App Watch App */;
targetProxy = ED28A8272EE5494A007F1AD7 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
ED28A8192EE548FE007F1AD7 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = ED28A8122EE548FC007F1AD7 /* FantasyWatch */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
ED28A81A2EE548FE007F1AD7 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = ED28A8122EE548FC007F1AD7 /* FantasyWatch */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
ED28A81C2EE548FE007F1AD7 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = ED28A8122EE548FC007F1AD7 /* FantasyWatch */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = FantasyWatch/FantasyWatch.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = WX5D6D5H8V;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FantasyWatch/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.michaelsimard.FantasyWatch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
ED28A81D2EE548FE007F1AD7 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = ED28A8122EE548FC007F1AD7 /* FantasyWatch */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = FantasyWatch/FantasyWatch.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = WX5D6D5H8V;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FantasyWatch/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.michaelsimard.FantasyWatch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
ED28A8312EE54953007F1AD7 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = ED28A8122EE548FC007F1AD7 /* FantasyWatch */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "FantasyWatch Watch App";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKWatchOnly = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.michaelsimard.FantasyWatch-Watch-App.watchkitapp";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
ED28A8322EE54953007F1AD7 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = ED28A8122EE548FC007F1AD7 /* FantasyWatch */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "FantasyWatch Watch App";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKWatchOnly = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.michaelsimard.FantasyWatch-Watch-App.watchkitapp";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
ED28A8352EE54953007F1AD7 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = ED28A8122EE548FC007F1AD7 /* FantasyWatch */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_KEY_CFBundleDisplayName = "FantasyWatch Watch App";
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.michaelsimard.FantasyWatch-Watch-App";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
ED28A8362EE54953007F1AD7 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = ED28A8122EE548FC007F1AD7 /* FantasyWatch */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_KEY_CFBundleDisplayName = "FantasyWatch Watch App";
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.michaelsimard.FantasyWatch-Watch-App";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
ED28A86E2EE5576E007F1AD7 /* Debug copy */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = ED28A8122EE548FC007F1AD7 /* FantasyWatch */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = "Debug copy";
};
ED28A86F2EE5576E007F1AD7 /* Debug copy */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = ED28A8122EE548FC007F1AD7 /* FantasyWatch */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = FantasyWatch/FantasyWatch.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = WX5D6D5H8V;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FantasyWatch/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.michaelsimard.FantasyWatch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = "Debug copy";
};
ED28A8702EE5576E007F1AD7 /* Debug copy */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = ED28A8122EE548FC007F1AD7 /* FantasyWatch */;
baseConfigurationReferenceRelativePath = Config.xcconfig;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_KEY_CFBundleDisplayName = "FantasyWatch Watch App";
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.michaelsimard.FantasyWatch-Watch-App";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = "Debug copy";
};
ED28A8712EE5576E007F1AD7 /* Debug copy */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "FantasyWatch Watch App";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKWatchOnly = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.michaelsimard.FantasyWatch-Watch-App.watchkitapp";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = "Debug copy";
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
ED28A80B2EE548FC007F1AD7 /* Build configuration list for PBXProject "FantasyWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
ED28A8192EE548FE007F1AD7 /* Debug */,
ED28A86E2EE5576E007F1AD7 /* Debug copy */,
ED28A81A2EE548FE007F1AD7 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
ED28A81B2EE548FE007F1AD7 /* Build configuration list for PBXNativeTarget "FantasyWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
ED28A81C2EE548FE007F1AD7 /* Debug */,
ED28A86F2EE5576E007F1AD7 /* Debug copy */,
ED28A81D2EE548FE007F1AD7 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
ED28A8302EE54953007F1AD7 /* Build configuration list for PBXNativeTarget "FantasyWatch Watch App Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
ED28A8312EE54953007F1AD7 /* Debug */,
ED28A8712EE5576E007F1AD7 /* Debug copy */,
ED28A8322EE54953007F1AD7 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
ED28A8342EE54953007F1AD7 /* Build configuration list for PBXNativeTarget "FantasyWatch Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
ED28A8352EE54953007F1AD7 /* Debug */,
ED28A8702EE5576E007F1AD7 /* Debug copy */,
ED28A8362EE54953007F1AD7 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
ED28A8372EE54D1B007F1AD7 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.23.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
ED28A8382EE54D1B007F1AD7 /* ComposableArchitecture */ = {
isa = XCSwiftPackageProductDependency;
package = ED28A8372EE54D1B007F1AD7 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */;
productName = ComposableArchitecture;
};
ED28A83A2EE54D2C007F1AD7 /* ComposableArchitecture */ = {
isa = XCSwiftPackageProductDependency;
package = ED28A8372EE54D1B007F1AD7 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */;
productName = ComposableArchitecture;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = ED28A8082EE548FC007F1AD7 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,132 @@
{
"originHash" : "e7024a9cd3fa1c40fa4003b3b2b186c00ba720c787de8ba274caf8fc530677e8",
"pins" : [
{
"identity" : "combine-schedulers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
"revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6",
"version" : "1.1.0"
}
},
{
"identity" : "swift-case-paths",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-case-paths",
"state" : {
"revision" : "6989976265be3f8d2b5802c722f9ba168e227c71",
"version" : "1.7.2"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
"version" : "1.0.6"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
"version" : "1.3.0"
}
},
{
"identity" : "swift-composable-architecture",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-composable-architecture",
"state" : {
"revision" : "5b0890fabfd68a2d375d68502bc3f54a8548c494",
"version" : "1.23.1"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
"version" : "1.3.3"
}
},
{
"identity" : "swift-dependencies",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies",
"state" : {
"revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc",
"version" : "1.10.0"
}
},
{
"identity" : "swift-identified-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-identified-collections",
"state" : {
"revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597",
"version" : "1.1.1"
}
},
{
"identity" : "swift-navigation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-navigation",
"state" : {
"revision" : "bf498690e1f6b4af790260f542e8428a4ba10d78",
"version" : "2.6.0"
}
},
{
"identity" : "swift-perception",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-perception",
"state" : {
"revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4",
"version" : "2.0.9"
}
},
{
"identity" : "swift-sharing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-sharing",
"state" : {
"revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
"version" : "2.7.4"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
"version" : "602.0.0"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "31073495cae9caf243c440eac94b3ab067e3d7bc",
"version" : "1.8.0"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,70 @@
//
// MatchupClient.swift
// FantasyWatch
//
// Created by Claude Code
//
import ComposableArchitecture
import Foundation
@DependencyClient
struct MatchupClient {
var fetchUserTeams: @Sendable () async throws -> [Team]
var fetchMatchup: @Sendable (String) async throws -> Matchup
var fetchRoster: @Sendable (String) async throws -> RosterStatus
}
extension MatchupClient: DependencyKey {
static let liveValue: MatchupClient = {
// This will be configured with the actual Yahoo API client
// For now, we create a placeholder that will need the OAuthManager injected
Self(
fetchUserTeams: {
// TODO: Inject YahooAPIClient instance
// return try await yahooAPIClient.getUserTeams()
throw NetworkError.invalidResponse
},
fetchMatchup: { teamKey in
// TODO: Inject YahooAPIClient instance
// return try await yahooAPIClient.getMatchup(teamKey: teamKey)
throw NetworkError.invalidResponse
},
fetchRoster: { teamKey in
// TODO: Inject YahooAPIClient instance
// return try await yahooAPIClient.getRoster(teamKey: teamKey)
throw NetworkError.invalidResponse
}
)
}()
static let testValue = Self(
fetchUserTeams: {
[
Team(teamKey: "test.l.123.t.1", teamName: "Test Team", leagueName: "Test League")
]
},
fetchMatchup: { _ in
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: [
CategoryScore(statID: "1", name: "G", userValue: "10", opponentValue: "8"),
CategoryScore(statID: "2", name: "A", userValue: "15", opponentValue: "18")
]
)
},
fetchRoster: { _ in
RosterStatus(activeCount: 10, benchedCount: 5, injuredReserve: 2)
}
)
}
extension DependencyValues {
var matchupClient: MatchupClient {
get { self[MatchupClient.self] }
set { self[MatchupClient.self] = newValue }
}
}

View File

@@ -0,0 +1,105 @@
//
// WatchConnectivityClient.swift
// FantasyWatch
//
// Created by Claude Code
//
import ComposableArchitecture
import Foundation
import WatchConnectivity
@DependencyClient
struct WatchConnectivityClient {
var sendMatchupToWatch: @Sendable (Matchup, RosterStatus) async throws -> Void
var refreshRequests: @Sendable () -> AsyncStream<Void>
}
extension WatchConnectivityClient: DependencyKey {
static let liveValue: WatchConnectivityClient = {
let manager = WatchConnectivityManager()
return Self(
sendMatchupToWatch: { matchup, roster in
try await manager.sendMatchupToWatch(matchup, roster: roster)
},
refreshRequests: {
manager.refreshRequestsStream()
}
)
}()
static let testValue = Self(
sendMatchupToWatch: { _, _ in },
refreshRequests: {
AsyncStream { _ in }
}
)
}
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 refreshContinuation: AsyncStream<Void>.Continuation?
override init() {
super.init()
guard WCSession.isSupported() else { return }
session = WCSession.default
session?.delegate = self
session?.activate()
}
func sendMatchupToWatch(_ matchup: Matchup, roster: RosterStatus) 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 payload = WatchPayload(matchup: matchup, roster: roster, timestamp: Date())
let data = try JSONEncoder().encode(payload)
let context = ["payload": data]
try session.updateApplicationContext(context)
}
func refreshRequestsStream() -> AsyncStream<Void> {
AsyncStream { continuation in
self.refreshContinuation = continuation
}
}
// MARK: - WCSessionDelegate
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("Watch session activation failed: \(error.localizedDescription)")
}
}
func sessionDidBecomeInactive(_ session: WCSession) {
print("Watch session became inactive")
}
func sessionDidDeactivate(_ session: WCSession) {
print("Watch session deactivated")
session.activate()
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
if message["type"] as? String == WatchMessage.refreshRequest.rawValue {
refreshContinuation?.yield()
}
}
}

View File

@@ -0,0 +1,30 @@
//
// ConfigurationManager.swift
// FantasyWatch
//
// Created by Claude Code
//
import Foundation
enum ConfigurationManager {
static var yahooClientID: String {
guard let clientID = Bundle.main.object(forInfoDictionaryKey: "YAHOO_CLIENT_ID") as? String,
!clientID.isEmpty else {
fatalError("YAHOO_CLIENT_ID not found in Info.plist. Please ensure Config.xcconfig is properly configured.")
}
return clientID
}
static var yahooClientSecret: String {
guard let clientSecret = Bundle.main.object(forInfoDictionaryKey: "YAHOO_CLIENT_SECRET") as? String,
!clientSecret.isEmpty else {
fatalError("YAHOO_CLIENT_SECRET not found in Info.plist. Please ensure Config.xcconfig is properly configured.")
}
return clientSecret
}
static var redirectURI: String {
"fantasyhockey://oauth-callback"
}
}

View File

@@ -0,0 +1,24 @@
//
// ContentView.swift
// FantasyWatch
//
// Created by Michael Simard on 12/6/25.
//
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
#Preview {
ContentView()
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array/>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
</dict>
</plist>

View File

@@ -0,0 +1,22 @@
//
// FantasyWatchApp.swift
// FantasyWatch
//
// Created by Michael Simard on 12/6/25.
//
import ComposableArchitecture
import SwiftUI
@main
struct FantasyWatchApp: App {
let store = Store(initialState: RootFeature.State()) {
RootFeature()
}
var body: some Scene {
WindowGroup {
RootView(store: store)
}
}
}

View File

@@ -0,0 +1,137 @@
//
// AuthenticationFeature.swift
// FantasyWatch
//
// Created by Claude Code
//
import ComposableArchitecture
import Foundation
@Reducer
struct AuthenticationFeature {
@ObservableState
struct State: Equatable {
var authenticationStatus: AuthStatus = .unauthenticated
var isLoading = false
var errorMessage: String?
var appleUserID: String?
var authorizationCode: String?
enum AuthStatus: Equatable {
case unauthenticated
case appleSignInComplete
case yahooOAuthInProgress
case authenticated
}
}
enum Action {
case signInWithAppleTapped
case appleSignInResponse(Result<String, Error>)
case startYahooOAuth
case yahooOAuthCallback(URL)
case authCodeExtracted(String)
case yahooTokenResponse(Result<TokenPair, Error>)
case dismissError
}
@Dependency(\.signInWithAppleClient) var signInWithAppleClient
@Dependency(\.yahooOAuthClient) var yahooOAuthClient
// These will be injected from app configuration
private let clientID: String
private let clientSecret: String
init(clientID: String = "", clientSecret: String = "") {
self.clientID = clientID
self.clientSecret = clientSecret
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .signInWithAppleTapped:
state.isLoading = true
state.errorMessage = nil
return .run { send in
await send(.appleSignInResponse(
Result { try await signInWithAppleClient.signIn() }
))
}
case .appleSignInResponse(.success(let userID)):
state.appleUserID = userID
state.authenticationStatus = .appleSignInComplete
state.isLoading = false
return .send(.startYahooOAuth)
case .appleSignInResponse(.failure(let error)):
state.isLoading = false
state.errorMessage = "Sign in with Apple failed: \(error.localizedDescription)"
return .none
case .startYahooOAuth:
state.authenticationStatus = .yahooOAuthInProgress
state.isLoading = true
return .run { [clientID] send in
try await yahooOAuthClient.startOAuthFlow(clientID, "fantasyhockey://oauth-callback")
} catch: { error, send in
await send(.yahooTokenResponse(.failure(error)))
}
case .yahooOAuthCallback(let url):
return .run { send in
await send(.authCodeExtracted(
Result { try await yahooOAuthClient.handleCallback(url) }.get()
))
} catch: { error, send in
await send(.yahooTokenResponse(.failure(error)))
}
case .authCodeExtracted(let code):
state.authorizationCode = code
return .run { [clientID, clientSecret] send in
await send(.yahooTokenResponse(
Result { try await yahooOAuthClient.exchangeCodeForToken(code, clientID, clientSecret) }
))
}
case .yahooTokenResponse(.success(let tokenPair)):
state.authenticationStatus = .authenticated
state.isLoading = false
// Token will be stored by the OAuthManager when injected
return .none
case .yahooTokenResponse(.failure(let error)):
state.isLoading = false
state.errorMessage = "Yahoo OAuth failed: \(error.localizedDescription)"
state.authenticationStatus = .appleSignInComplete
return .none
case .dismissError:
state.errorMessage = nil
return .none
}
}
}
}
extension AuthenticationFeature.Action: Equatable {
static func == (lhs: AuthenticationFeature.Action, rhs: AuthenticationFeature.Action) -> Bool {
switch (lhs, rhs) {
case (.signInWithAppleTapped, .signInWithAppleTapped):
return true
case (.startYahooOAuth, .startYahooOAuth):
return true
case let (.yahooOAuthCallback(lhsURL), .yahooOAuthCallback(rhsURL)):
return lhsURL == rhsURL
case let (.authCodeExtracted(lhsCode), .authCodeExtracted(rhsCode)):
return lhsCode == rhsCode
case (.dismissError, .dismissError):
return true
default:
return false
}
}
}

View File

@@ -0,0 +1,80 @@
//
// AuthenticationView.swift
// FantasyWatch
//
// Created by Claude Code
//
import AuthenticationServices
import ComposableArchitecture
import SwiftUI
struct AuthenticationView: View {
let store: StoreOf<AuthenticationFeature>
var body: some View {
WithPerceptionTracking {
VStack(spacing: 20) {
Image(systemName: "hockey.puck.fill")
.font(.system(size: 60))
.foregroundColor(.blue)
Text("Fantasy Hockey")
.font(.largeTitle)
.fontWeight(.bold)
Text("Track your matchup scores on your wrist")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Spacer()
if store.isLoading {
ProgressView()
.scaleEffect(1.5)
} else {
SignInWithAppleButton(
.signIn,
onRequest: { request in
request.requestedScopes = [.fullName, .email]
},
onCompletion: { result in
store.send(.signInWithAppleTapped)
}
)
.frame(height: 50)
.padding(.horizontal, 40)
}
if let error = store.errorMessage {
Text(error)
.font(.caption)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
Spacer()
Text("Signing in will connect to your Yahoo Fantasy account")
.font(.caption2)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.padding()
}
}
}
#Preview {
AuthenticationView(
store: Store(
initialState: AuthenticationFeature.State()
) {
AuthenticationFeature()
}
)
}

View File

@@ -0,0 +1,74 @@
//
// SignInWithAppleClient.swift
// FantasyWatch
//
// Created by Claude Code
//
import AuthenticationServices
import ComposableArchitecture
import Foundation
@DependencyClient
struct SignInWithAppleClient {
var signIn: @Sendable () async throws -> String
}
extension SignInWithAppleClient: DependencyKey {
static let liveValue = Self(
signIn: {
try await withCheckedThrowingContinuation { continuation in
let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
request.requestedScopes = [.fullName, .email]
let authorizationController = ASAuthorizationController(authorizationRequests: [request])
let delegate = SignInWithAppleDelegate(continuation: continuation)
authorizationController.delegate = delegate
authorizationController.performRequests()
// Keep delegate alive
withExtendedLifetime(delegate) {}
}
}
)
static let testValue = Self(
signIn: { "test-user-id" }
)
}
extension DependencyValues {
var signInWithAppleClient: SignInWithAppleClient {
get { self[SignInWithAppleClient.self] }
set { self[SignInWithAppleClient.self] = newValue }
}
}
private final class SignInWithAppleDelegate: NSObject, ASAuthorizationControllerDelegate {
let continuation: CheckedContinuation<String, Error>
init(continuation: CheckedContinuation<String, Error>) {
self.continuation = continuation
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else {
continuation.resume(throwing: OAuthError.authorizationFailed)
return
}
let userIdentifier = appleIDCredential.user
// Store in iCloud
NSUbiquitousKeyValueStore.default.set(userIdentifier, forKey: "com.fantasyhockey.appleUserID")
NSUbiquitousKeyValueStore.default.synchronize()
continuation.resume(returning: userIdentifier)
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
continuation.resume(throwing: error)
}
}

View File

@@ -0,0 +1,113 @@
//
// YahooOAuthClient.swift
// FantasyWatch
//
// Created by Claude Code
//
import ComposableArchitecture
import Foundation
import SafariServices
import UIKit
@DependencyClient
struct YahooOAuthClient {
var startOAuthFlow: @Sendable (String, String) async throws -> Void
var handleCallback: @Sendable (URL) async throws -> String
var exchangeCodeForToken: @Sendable (String, String, String) async throws -> TokenPair
}
extension YahooOAuthClient: DependencyKey {
static let liveValue = Self(
startOAuthFlow: { clientID, redirectURI in
var components = URLComponents(string: "https://api.login.yahoo.com/oauth2/request_auth")!
components.queryItems = [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "language", value: "en-us")
]
guard let url = components.url else {
throw OAuthError.invalidResponse
}
await MainActor.run {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController else {
return
}
let safariVC = SFSafariViewController(url: url)
rootViewController.present(safariVC, animated: true)
}
},
handleCallback: { url in
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let code = components.queryItems?.first(where: { $0.name == "code" })?.value else {
throw OAuthError.invalidResponse
}
// Dismiss Safari view controller
await MainActor.run {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController else {
return
}
rootViewController.dismiss(animated: true)
}
return code
},
exchangeCodeForToken: { code, clientID, clientSecret in
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": "authorization_code",
"code": code,
"redirect_uri": "fantasyhockey://oauth-callback",
"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)
return tokenResponse.toTokenPair()
}
)
static let testValue = Self(
startOAuthFlow: { _, _ in },
handleCallback: { _ in "test-code" },
exchangeCodeForToken: { _, _, _ in
TokenPair(
accessToken: "test-access-token",
refreshToken: "test-refresh-token",
expiresIn: 3600,
tokenType: "bearer"
)
}
)
}
extension DependencyValues {
var yahooOAuthClient: YahooOAuthClient {
get { self[YahooOAuthClient.self] }
set { self[YahooOAuthClient.self] = newValue }
}
}

View File

@@ -0,0 +1,149 @@
//
// MatchupDetailView.swift
// FantasyWatch
//
// Created by Claude Code
//
import SwiftUI
struct MatchupDetailView: View {
let matchup: Matchup
let roster: RosterStatus?
var body: some View {
List {
Section("Matchup Week \(matchup.week)") {
HStack {
VStack(alignment: .leading) {
Text(matchup.userTeam.teamName)
.font(.headline)
Text("\(matchup.userTeam.wins)-\(matchup.userTeam.losses)-\(matchup.userTeam.ties)")
.foregroundColor(.blue)
}
Spacer()
Text("vs")
.foregroundColor(.secondary)
Spacer()
VStack(alignment: .trailing) {
Text(matchup.opponentTeam.teamName)
.font(.headline)
Text("\(matchup.opponentTeam.wins)-\(matchup.opponentTeam.losses)-\(matchup.opponentTeam.ties)")
}
}
}
Section("Categories") {
ForEach(matchup.categories) { category in
HStack {
Text(category.name)
.font(.subheadline)
.fontWeight(.semibold)
.frame(width: 60, alignment: .leading)
Spacer()
Text(category.userValue)
.font(.body)
.fontWeight(.semibold)
.foregroundColor(colorForComparison(category.comparison))
Text("vs")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 8)
Text(category.opponentValue)
.font(.body)
.foregroundColor(.secondary)
comparisonIcon(category.comparison)
.font(.caption)
.frame(width: 20)
}
}
}
if let roster = roster {
Section("Roster Status") {
HStack {
Text("Active Players")
Spacer()
Text("\(roster.activeCount)")
.fontWeight(.semibold)
}
HStack {
Text("Benched")
Spacer()
Text("\(roster.benchedCount)")
.foregroundColor(.secondary)
}
HStack {
Text("Injured Reserve")
Spacer()
Text("\(roster.injuredReserve)")
.fontWeight(roster.injuredReserve > 0 ? .semibold : .regular)
.foregroundColor(roster.injuredReserve > 0 ? .red : .primary)
}
HStack {
Text("Total Roster")
Spacer()
Text("\(roster.totalPlayers)")
.fontWeight(.semibold)
}
}
}
}
.navigationTitle("Category Breakdown")
.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.circle.fill")
.foregroundColor(.green)
case .losing:
Image(systemName: "arrow.down.circle.fill")
.foregroundColor(.red)
case .tied:
Image(systemName: "equal.circle.fill")
.foregroundColor(.orange)
}
}
}
#Preview {
NavigationStack {
MatchupDetailView(
matchup: Matchup(
week: 10,
status: "midevent",
userTeam: TeamScore(teamKey: "test", teamName: "My Team", wins: 5, losses: 3, ties: 1),
opponentTeam: TeamScore(teamKey: "test2", 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"),
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")
]
),
roster: RosterStatus(activeCount: 10, benchedCount: 5, injuredReserve: 2)
)
}
}

View File

@@ -0,0 +1,130 @@
//
// MatchupFeature.swift
// FantasyWatch
//
// Created by Claude Code
//
import ComposableArchitecture
import Foundation
@Reducer
struct MatchupFeature {
@ObservableState
struct State: Equatable {
var matchup: Matchup?
var roster: RosterStatus?
var teams: [Team] = []
var selectedTeamKey: String?
var isLoading = false
var errorMessage: String?
}
enum Action {
case onAppear
case teamsResponse(Result<[Team], Error>)
case refreshTapped
case matchupResponse(Result<Matchup, Error>)
case rosterResponse(Result<RosterStatus, Error>)
case sendDataToWatch
case dismissError
}
@Dependency(\.matchupClient) var matchupClient
@Dependency(\.watchConnectivityClient) var watchConnectivityClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.isLoading = true
return .run { send in
await send(.teamsResponse(
Result { try await matchupClient.fetchUserTeams() }
))
}
case .teamsResponse(.success(let teams)):
state.teams = teams
state.selectedTeamKey = teams.first?.teamKey
state.isLoading = false
if teams.isEmpty {
state.errorMessage = "No teams found. Please check your Yahoo Fantasy account."
return .none
}
return .send(.refreshTapped)
case .teamsResponse(.failure(let error)):
state.isLoading = false
state.errorMessage = "Failed to load teams: \(error.localizedDescription)"
return .none
case .refreshTapped:
guard let teamKey = state.selectedTeamKey else {
state.errorMessage = "No team selected"
return .none
}
state.isLoading = true
state.errorMessage = nil
return .run { send in
async let matchup = matchupClient.fetchMatchup(teamKey)
async let roster = matchupClient.fetchRoster(teamKey)
await send(.matchupResponse(Result { try await matchup }))
await send(.rosterResponse(Result { try await roster }))
}
case .matchupResponse(.success(let matchup)):
state.matchup = matchup
state.isLoading = false
return .send(.sendDataToWatch)
case .matchupResponse(.failure(let error)):
state.isLoading = false
state.errorMessage = "Failed to load matchup: \(error.localizedDescription)"
return .none
case .rosterResponse(.success(let roster)):
state.roster = roster
return .send(.sendDataToWatch)
case .rosterResponse(.failure(let error)):
state.errorMessage = "Failed to load roster: \(error.localizedDescription)"
return .none
case .sendDataToWatch:
guard let matchup = state.matchup,
let roster = state.roster else {
return .none
}
return .run { _ in
try await watchConnectivityClient.sendMatchupToWatch(matchup, roster)
} catch: { error, send in
await send(.dismissError)
}
case .dismissError:
state.errorMessage = nil
return .none
}
}
}
}
extension MatchupFeature.Action: Equatable {
static func == (lhs: MatchupFeature.Action, rhs: MatchupFeature.Action) -> Bool {
switch (lhs, rhs) {
case (.onAppear, .onAppear):
return true
case (.refreshTapped, .refreshTapped):
return true
case (.sendDataToWatch, .sendDataToWatch):
return true
case (.dismissError, .dismissError):
return true
default:
return false
}
}
}

View File

@@ -0,0 +1,254 @@
//
// MatchupView.swift
// FantasyWatch
//
// Created by Claude Code
//
import ComposableArchitecture
import SwiftUI
struct MatchupView: View {
let store: StoreOf<MatchupFeature>
var body: some View {
WithPerceptionTracking {
NavigationStack {
VStack(spacing: 20) {
if store.isLoading {
ProgressView()
.scaleEffect(1.5)
Text("Loading matchup...")
.font(.caption)
.foregroundColor(.secondary)
} else if let matchup = store.matchup {
matchupContentView(matchup: matchup)
} else {
emptyStateView
}
if let error = store.errorMessage {
errorView(error)
}
Spacer()
}
.padding()
.navigationTitle("Fantasy Hockey")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
store.send(.refreshTapped)
} label: {
Image(systemName: "arrow.clockwise")
}
.disabled(store.isLoading)
}
}
.onAppear {
store.send(.onAppear)
}
}
}
}
@ViewBuilder
private func matchupContentView(matchup: Matchup) -> some View {
VStack(spacing: 15) {
// Week and Status
Text("Week \(matchup.week)")
.font(.headline)
.foregroundColor(.secondary)
// Score Display
HStack(spacing: 30) {
VStack {
Text("You")
.font(.caption)
.foregroundColor(.secondary)
Text(matchup.userTeam.teamName)
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.center)
Text("\(matchup.userTeam.wins)-\(matchup.userTeam.losses)-\(matchup.userTeam.ties)")
.font(.title)
.fontWeight(.bold)
.foregroundColor(.blue)
}
.frame(maxWidth: .infinity)
Text("vs")
.font(.caption)
.foregroundColor(.secondary)
VStack {
Text("Opponent")
.font(.caption)
.foregroundColor(.secondary)
Text(matchup.opponentTeam.teamName)
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.center)
Text("\(matchup.opponentTeam.wins)-\(matchup.opponentTeam.losses)-\(matchup.opponentTeam.ties)")
.font(.title)
.fontWeight(.bold)
}
.frame(maxWidth: .infinity)
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
// Category Summary
HStack(spacing: 20) {
statBadge(
title: "Winning",
count: matchup.categories.filter { $0.comparison == .winning }.count,
color: .green
)
statBadge(
title: "Tied",
count: matchup.categories.filter { $0.comparison == .tied }.count,
color: .orange
)
statBadge(
title: "Losing",
count: matchup.categories.filter { $0.comparison == .losing }.count,
color: .red
)
}
// Roster Status
if let roster = store.roster {
rosterStatusView(roster: roster)
}
NavigationLink {
MatchupDetailView(matchup: matchup, roster: store.roster)
} label: {
Text("View Category Breakdown")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
}
}
private var emptyStateView: some View {
VStack(spacing: 15) {
Image(systemName: "hockey.puck")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text("No matchup data")
.font(.headline)
Text("Pull to refresh or check your connection")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
private func errorView(_ error: String) -> some View {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(error)
.font(.caption)
.foregroundColor(.red)
Button {
store.send(.dismissError)
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(8)
}
private func statBadge(title: String, count: Int, color: Color) -> some View {
VStack(spacing: 5) {
Text("\(count)")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(color)
Text(title)
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(color.opacity(0.1))
.cornerRadius(8)
}
private func rosterStatusView(roster: RosterStatus) -> some View {
HStack(spacing: 15) {
VStack {
Text("\(roster.activeCount)")
.font(.title3)
.fontWeight(.semibold)
Text("Active")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
Divider()
VStack {
Text("\(roster.benchedCount)")
.font(.title3)
.fontWeight(.semibold)
Text("Bench")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
Divider()
VStack {
Text("\(roster.injuredReserve)")
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(roster.injuredReserve > 0 ? .red : .primary)
Text("IR")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(10)
}
}
#Preview {
MatchupView(
store: Store(
initialState: MatchupFeature.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 Team", 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)
)
) {
MatchupFeature()
}
)
}

View File

@@ -0,0 +1,50 @@
//
// RootFeature.swift
// FantasyWatch
//
// Created by Claude Code
//
import ComposableArchitecture
import Foundation
@Reducer
struct RootFeature {
@ObservableState
struct State: Equatable {
var authentication = AuthenticationFeature.State()
var matchup: MatchupFeature.State?
}
enum Action {
case authentication(AuthenticationFeature.Action)
case matchup(MatchupFeature.Action)
case handleOAuthCallback(URL)
}
var body: some ReducerOf<Self> {
Scope(state: \.authentication, action: \.authentication) {
AuthenticationFeature(
clientID: ConfigurationManager.yahooClientID,
clientSecret: ConfigurationManager.yahooClientSecret
)
}
Reduce { state, action in
switch action {
case .authentication(.yahooTokenResponse(.success)):
state.matchup = MatchupFeature.State()
return .none
case .handleOAuthCallback(let url):
return .send(.authentication(.yahooOAuthCallback(url)))
default:
return .none
}
}
.ifLet(\.matchup, action: \.matchup) {
MatchupFeature()
}
}
}

View File

@@ -0,0 +1,43 @@
//
// RootView.swift
// FantasyWatch
//
// Created by Claude Code
//
import ComposableArchitecture
import SwiftUI
struct RootView: View {
let store: StoreOf<RootFeature>
var body: some View {
WithPerceptionTracking {
Group {
if store.authentication.authenticationStatus == .authenticated,
let matchupStore = store.scope(state: \.matchup, action: \.matchup) {
MatchupView(store: matchupStore)
} else {
AuthenticationView(
store: store.scope(state: \.authentication, action: \.authentication)
)
}
}
.onOpenURL { url in
if url.scheme == "fantasyhockey" {
store.send(.handleOAuthCallback(url))
}
}
}
}
}
#Preview {
RootView(
store: Store(
initialState: RootFeature.State()
) {
RootFeature()
}
)
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.michaelsimard.fantasyhockey.oauth</string>
<key>CFBundleURLSchemes</key>
<array>
<string>fantasyhockey</string>
</array>
</dict>
</array>
<key>YAHOO_CLIENT_ID</key>
<string>$(YAHOO_CLIENT_ID)</string>
<key>YAHOO_CLIENT_SECRET</key>
<string>$(YAHOO_CLIENT_SECRET)</string>
</dict>
</plist>

View File

@@ -0,0 +1,58 @@
//
// MatchupModels.swift
// FantasyWatch
//
// Created by Claude Code
//
import Foundation
struct Matchup: Codable, Equatable, Sendable {
let week: Int
let status: String
let userTeam: TeamScore
let opponentTeam: TeamScore
let categories: [CategoryScore]
}
struct TeamScore: Codable, Equatable, Sendable {
let teamKey: String
let teamName: String
let wins: Int
let losses: Int
let ties: Int
}
struct CategoryScore: Codable, Equatable, Sendable, Identifiable {
let statID: String
let name: String
let userValue: String
let opponentValue: String
var id: String { statID }
enum ComparisonResult {
case winning
case losing
case tied
}
var comparison: ComparisonResult {
guard let userNum = Double(userValue),
let opponentNum = Double(opponentValue) else {
return userValue == opponentValue ? .tied : .winning
}
let isInvertedStat = name == "GAA" || name == "ERA"
if isInvertedStat {
if userNum < opponentNum { return .winning }
if userNum > opponentNum { return .losing }
return .tied
} else {
if userNum > opponentNum { return .winning }
if userNum < opponentNum { return .losing }
return .tied
}
}
}

View File

@@ -0,0 +1,18 @@
//
// RosterModels.swift
// FantasyWatch
//
// Created by Claude Code
//
import Foundation
struct RosterStatus: Codable, Equatable, Sendable {
let activeCount: Int
let benchedCount: Int
let injuredReserve: Int
var totalPlayers: Int {
activeCount + benchedCount + injuredReserve
}
}

View File

@@ -0,0 +1,16 @@
//
// TeamModels.swift
// FantasyWatch
//
// Created by Claude Code
//
import Foundation
struct Team: Codable, Equatable, Sendable, Identifiable {
let teamKey: String
let teamName: String
let leagueName: String
var id: String { teamKey }
}

View File

@@ -0,0 +1,27 @@
//
// Endpoint.swift
// FantasyWatch
//
// Created by Claude Code
//
import Foundation
protocol Endpoint {
var path: String { get }
var method: HTTPMethod { get }
var headers: [String: String]? { get }
var queryParameters: [String: String]? { get }
}
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
extension Endpoint {
var headers: [String: String]? { nil }
var queryParameters: [String: String]? { nil }
}

View File

@@ -0,0 +1,35 @@
//
// NetworkError.swift
// FantasyWatch
//
// Created by Claude Code
//
import Foundation
enum NetworkError: Error, Equitable {
case unauthorized
case serverError(statusCode: Int)
case decodingError(Error)
case networkFailure(Error)
case invalidURL
case invalidResponse
case noData
static func == (lhs: NetworkError, rhs: NetworkError) -> Bool {
switch (lhs, rhs) {
case (.unauthorized, .unauthorized):
return true
case let (.serverError(lhsCode), .serverError(rhsCode)):
return lhsCode == rhsCode
case (.invalidURL, .invalidURL):
return true
case (.invalidResponse, .invalidResponse):
return true
case (.noData, .noData):
return true
default:
return false
}
}
}

View File

@@ -0,0 +1,78 @@
//
// NetworkService.swift
// FantasyWatch
//
// Created by Claude Code
//
import Foundation
protocol NetworkService {
func request(
_ endpoint: Endpoint,
baseURL: String,
bearerToken: String?
) async throws -> Data
}
final class DefaultNetworkService: NetworkService {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func request(
_ endpoint: Endpoint,
baseURL: String,
bearerToken: String? = nil
) async throws -> Data {
guard var urlComponents = URLComponents(string: baseURL + endpoint.path) else {
throw NetworkError.invalidURL
}
if let queryParameters = endpoint.queryParameters {
urlComponents.queryItems = queryParameters.map {
URLQueryItem(name: $0.key, value: $0.value)
}
}
guard let url = urlComponents.url else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
if let headers = endpoint.headers {
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
}
if let token = bearerToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
return data
case 401:
throw NetworkError.unauthorized
default:
throw NetworkError.serverError(statusCode: httpResponse.statusCode)
}
} catch let error as NetworkError {
throw error
} catch {
throw NetworkError.networkFailure(error)
}
}
}

View 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)
}
}

View 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
}

View 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()
}
}

View File

@@ -0,0 +1,61 @@
//
// XMLResponseDecoder.swift
// FantasyWatch
//
// Created by Claude Code
//
import Foundation
final class XMLResponseDecoder: NSObject, XMLParserDelegate {
private var currentElement = ""
private var currentValue = ""
private var elementStack: [String] = []
private var dataDict: [String: Any] = [:]
private var arrayStack: [[String: Any]] = []
func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
let parser = XMLParser(data: data)
parser.delegate = self
guard parser.parse() else {
throw NetworkError.decodingError(
NSError(domain: "XMLDecoder", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse XML"])
)
}
let jsonData = try JSONSerialization.data(withJSONObject: dataDict)
return try JSONDecoder().decode(T.self, from: jsonData)
}
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
currentElement = elementName
elementStack.append(elementName)
currentValue = ""
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
currentValue += string.trimmingCharacters(in: .whitespacesAndNewlines)
}
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
elementStack.removeLast()
if !currentValue.isEmpty {
let path = elementStack.joined(separator: ".")
dataDict[elementName] = currentValue
if !path.isEmpty {
dataDict[path + "." + elementName] = currentValue
}
}
currentValue = ""
}
}
// NOTE: This is a simplified XML decoder for POC purposes.
// The Yahoo Fantasy API returns complex nested XML structures.
// For production, we would need to implement more sophisticated parsing
// that handles arrays, nested objects, and the specific Yahoo XML schema.
// For now, we will parse the specific endpoints we need with custom logic.

View File

@@ -0,0 +1,89 @@
//
// YahooAPIClient.swift
// FantasyWatch
//
// Created by Claude Code
//
import Foundation
final class YahooAPIClient {
private let networkService: NetworkService
private let oauthManager: OAuthManager
private let baseURL = "https://fantasysports.yahooapis.com/fantasy/v2"
init(
networkService: NetworkService = DefaultNetworkService(),
oauthManager: OAuthManager
) {
self.networkService = networkService
self.oauthManager = oauthManager
}
func getUserTeams() async throws -> [Team] {
let token = try await oauthManager.validToken()
let data = try await networkService.request(
YahooEndpoint.userTeams,
baseURL: baseURL,
bearerToken: token
)
// NOTE: Yahoo API returns XML by default, even with format=json parameter
// For POC, we will need to parse the actual XML response
// This is a placeholder that will need proper XML parsing implementation
// based on the actual Yahoo API response structure
// For now, return mock data structure
// TODO: Implement proper XML parsing once we have real API responses
return try parseTeamsFromXML(data)
}
func getMatchup(teamKey: String) async throws -> Matchup {
let token = try await oauthManager.validToken()
let data = try await networkService.request(
YahooEndpoint.matchup(teamKey: teamKey),
baseURL: baseURL,
bearerToken: token
)
return try parseMatchupFromXML(data)
}
func getRoster(teamKey: String) async throws -> RosterStatus {
let token = try await oauthManager.validToken()
let data = try await networkService.request(
YahooEndpoint.roster(teamKey: teamKey),
baseURL: baseURL,
bearerToken: token
)
return try parseRosterFromXML(data)
}
// MARK: - XML Parsing Helpers
private func parseTeamsFromXML(_ data: Data) throws -> [Team] {
// TODO: Implement proper XML parsing
// This is a placeholder for POC
// The actual implementation will parse the Yahoo XML response structure
throw NetworkError.decodingError(
NSError(domain: "YahooAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "XML parsing not yet implemented"])
)
}
private func parseMatchupFromXML(_ data: Data) throws -> Matchup {
// TODO: Implement proper XML parsing
// This is a placeholder for POC
throw NetworkError.decodingError(
NSError(domain: "YahooAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "XML parsing not yet implemented"])
)
}
private func parseRosterFromXML(_ data: Data) throws -> RosterStatus {
// TODO: Implement proper XML parsing
// This is a placeholder for POC
throw NetworkError.decodingError(
NSError(domain: "YahooAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "XML parsing not yet implemented"])
)
}
}

View File

@@ -0,0 +1,33 @@
//
// YahooEndpoints.swift
// FantasyWatch
//
// Created by Claude Code
//
import Foundation
enum YahooEndpoint: Endpoint {
case userTeams
case matchup(teamKey: String)
case roster(teamKey: String)
var path: String {
switch self {
case .userTeams:
return "/users;use_login=1/games;game_keys=nhl/teams"
case .matchup(let teamKey):
return "/team/\(teamKey)/matchups"
case .roster(let teamKey):
return "/team/\(teamKey)/roster"
}
}
var method: HTTPMethod {
.get
}
var queryParameters: [String: String]? {
["format": "json"]
}
}

View File

@@ -0,0 +1,20 @@
//
// MessageTypes.swift
// FantasyWatch
//
// Created by Claude Code
//
import Foundation
enum WatchMessage: String, Codable {
case matchupUpdate
case refreshRequest
case authenticationRequired
}
struct WatchPayload: Codable, Sendable {
let matchup: Matchup
let roster: RosterStatus
let timestamp: Date
}