diff --git a/Examples/Examples.xcworkspace/contents.xcworkspacedata b/Examples/Examples.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..443e4f2f
--- /dev/null
+++ b/Examples/Examples.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Examples/Examples.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Examples/Examples.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 00000000..18d98100
--- /dev/null
+++ b/Examples/Examples.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/Examples/Examples.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 00000000..14d2dd7d
--- /dev/null
+++ b/Examples/Examples.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,212 @@
+{
+ "pins" : [
+ {
+ "identity" : "combine-schedulers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/combine-schedulers",
+ "state" : {
+ "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13",
+ "version" : "1.0.2"
+ }
+ },
+ {
+ "identity" : "swift-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-algorithms",
+ "state" : {
+ "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42",
+ "version" : "1.2.0"
+ }
+ },
+ {
+ "identity" : "swift-argument-parser",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-argument-parser",
+ "state" : {
+ "revision" : "41982a3656a71c768319979febd796c6fd111d5c",
+ "version" : "1.5.0"
+ }
+ },
+ {
+ "identity" : "swift-async-algorithms",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-async-algorithms",
+ "state" : {
+ "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20",
+ "version" : "1.0.1"
+ }
+ },
+ {
+ "identity" : "swift-case-paths",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-case-paths",
+ "state" : {
+ "revision" : "642e6aab8e03e5f992d9c83e38c5be98cfad5078",
+ "version" : "1.5.5"
+ }
+ },
+ {
+ "identity" : "swift-clocks",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-clocks",
+ "state" : {
+ "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53",
+ "version" : "1.0.5"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections",
+ "state" : {
+ "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06",
+ "version" : "1.1.3"
+ }
+ },
+ {
+ "identity" : "swift-composable-architecture",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-composable-architecture",
+ "state" : {
+ "revision" : "0d8980f5bcc5fe6941f1788758667ff2b8c10f03",
+ "version" : "1.14.0"
+ }
+ },
+ {
+ "identity" : "swift-composable-loadable",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/danthorpe/swift-composable-loadable",
+ "state" : {
+ "branch" : "main",
+ "revision" : "d21a0f5c4931acbdd80fa505d4a08aaee97aefb0"
+ }
+ },
+ {
+ "identity" : "swift-concurrency-extras",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
+ "state" : {
+ "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71",
+ "version" : "1.1.0"
+ }
+ },
+ {
+ "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" : "3ef38bb702a1a2f39c7e19fc0578403b8ee52b17",
+ "version" : "1.3.9"
+ }
+ },
+ {
+ "identity" : "swift-docc-plugin",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-docc-plugin",
+ "state" : {
+ "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64",
+ "version" : "1.4.3"
+ }
+ },
+ {
+ "identity" : "swift-docc-symbolkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-docc-symbolkit",
+ "state" : {
+ "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
+ "version" : "1.0.0"
+ }
+ },
+ {
+ "identity" : "swift-http-types",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-http-types",
+ "state" : {
+ "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-identified-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-identified-collections",
+ "state" : {
+ "revision" : "2f5ab6e091dd032b63dacbda052405756010dc3b",
+ "version" : "1.1.0"
+ }
+ },
+ {
+ "identity" : "swift-navigation",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-navigation",
+ "state" : {
+ "revision" : "e834b3760731160d7d448509ee6a1408c8582a6b",
+ "version" : "2.2.0"
+ }
+ },
+ {
+ "identity" : "swift-numerics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-numerics.git",
+ "state" : {
+ "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b",
+ "version" : "1.0.2"
+ }
+ },
+ {
+ "identity" : "swift-perception",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-perception",
+ "state" : {
+ "revision" : "bc67aa8e461351c97282c2419153757a446ae1c9",
+ "version" : "1.3.5"
+ }
+ },
+ {
+ "identity" : "swift-syntax",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-syntax",
+ "state" : {
+ "revision" : "515f79b522918f83483068d99c68daeb5116342d",
+ "version" : "600.0.0-prerelease-2024-08-20"
+ }
+ },
+ {
+ "identity" : "swift-tagged",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-tagged",
+ "state" : {
+ "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
+ "version" : "0.10.0"
+ }
+ },
+ {
+ "identity" : "swift-utilities",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/danthorpe/swift-utilities",
+ "state" : {
+ "revision" : "615c4fbf115a584057b978b2c18a4c75198f4d61",
+ "version" : "0.7.0"
+ }
+ },
+ {
+ "identity" : "xctest-dynamic-overlay",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
+ "state" : {
+ "revision" : "96beb108a57f24c8476ae1f309239270772b2940",
+ "version" : "1.2.5"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/Examples/Spotify/.gitignore b/Examples/Spotify/.gitignore
new file mode 100644
index 00000000..0023a534
--- /dev/null
+++ b/Examples/Spotify/.gitignore
@@ -0,0 +1,8 @@
+.DS_Store
+/.build
+/Packages
+xcuserdata/
+DerivedData/
+.swiftpm/configuration/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
diff --git a/Examples/Spotify/App/Artists/ArtistsFeature.swift b/Examples/Spotify/App/Artists/ArtistsFeature.swift
new file mode 100644
index 00000000..8d8558bd
--- /dev/null
+++ b/Examples/Spotify/App/Artists/ArtistsFeature.swift
@@ -0,0 +1,17 @@
+import ComposableArchitecture
+import ComposableLoadable
+import Foundation
+
+@Reducer
+enum ArtistsFeature {
+ @ReducerCaseIgnored
+ case empty
+ case artists(PaginationFeature)
+}
+
+extension PaginationFeature {
+ init() {
+ @Dependency(\.spotify) var spotify
+ self.init(loadPage: spotify.paginateFollowedArtists)
+ }
+}
diff --git a/Examples/Spotify/App/Artists/ArtistsView.swift b/Examples/Spotify/App/Artists/ArtistsView.swift
new file mode 100644
index 00000000..6ec33515
--- /dev/null
+++ b/Examples/Spotify/App/Artists/ArtistsView.swift
@@ -0,0 +1,72 @@
+import ComposableArchitecture
+import ComposableLoadable
+import SwiftUI
+
+struct ArtistsView {
+ let store: StoreOf
+
+ #if os(macOS)
+ let columns = Array(repeating: GridItem(.fixed(160), spacing: 0), count: 4)
+ #elseif os(tvOS)
+ let columns = Array(repeating: GridItem(.fixed(256), spacing: 0), count: 5)
+ #else
+ let columns = Array(repeating: GridItem(.fixed(120), spacing: 0), count: 3)
+ #endif
+}
+
+extension ArtistsView: View {
+
+ var body: some View {
+ WithPerceptionTracking {
+ contentView
+ }
+ }
+
+ @ViewBuilder
+ private var contentView: some View {
+ switch store.case {
+ case .empty:
+ Text("No Artists")
+ case let .artists(store):
+ ScrollView(.vertical) {
+ LazyVGrid(columns: columns, spacing: 0) {
+ ForEach(store.elements.sorted().reversed()) { artist in
+ ArtistView(artist: artist)
+ }
+
+ PaginationLoadMore(store, direction: .bottom) { error, _ in
+ Text("Error fetching next page: \(error)")
+ } noMoreResults: {
+ Text("No more artists")
+ } onActive: {
+ ProgressView()
+ }
+ }
+ }
+ }
+ }
+}
+
+struct ArtistView: View {
+ @Environment(\.displayScale) var displayScale
+ let artist: Artist
+ var body: some View {
+ ZStack(alignment: .bottom) {
+ AsyncImage(url: artist.images.first?.url, scale: displayScale) { phase in
+ switch phase {
+ case let .success(image):
+ image.resizable()
+ .aspectRatio(1.0, contentMode: .fit)
+ default:
+ EmptyView()
+ }
+ }
+ .clipped()
+
+ Text(artist.name)
+ .padding(.vertical, 8)
+ .frame(maxWidth: .greatestFiniteMagnitude)
+ .background(.ultraThinMaterial, in: Rectangle())
+ }
+ }
+}
diff --git a/Examples/Spotify/App/Artists/SpotifyClient+PaginatingArtists.swift b/Examples/Spotify/App/Artists/SpotifyClient+PaginatingArtists.swift
new file mode 100644
index 00000000..5c40ea28
--- /dev/null
+++ b/Examples/Spotify/App/Artists/SpotifyClient+PaginatingArtists.swift
@@ -0,0 +1,15 @@
+import ComposableLoadable
+
+extension Spotify.Client {
+ @Sendable func paginateFollowedArtists(
+ _ request: PaginationFeature.PageRequest
+ ) async throws -> PaginationFeature.Page {
+ let artists = try await followedArtists(after: request.cursor).artists
+ return PaginationFeature
+ .Page(
+ previous: artists.cursors.before,
+ next: artists.cursors.after,
+ elements: artists.items
+ )
+ }
+}
diff --git a/Examples/Spotify/App/SignedIn/SignedInFeature.swift b/Examples/Spotify/App/SignedIn/SignedInFeature.swift
new file mode 100644
index 00000000..5ccf4978
--- /dev/null
+++ b/Examples/Spotify/App/SignedIn/SignedInFeature.swift
@@ -0,0 +1,56 @@
+import ComposableArchitecture
+import ComposableLoadable
+import Tagged
+
+@Reducer
+struct SignedInFeature {
+
+ @ObservableState
+ struct State {
+ @ObservationStateIgnored
+ @LoadableStateWith var followedArtists
+
+ init(
+ followedArtists: LoadableStateWith
+ ) {
+ self._followedArtists = followedArtists
+ }
+
+ init() {
+ self.init(followedArtists: .pending)
+ }
+ }
+
+ enum Action {
+ case followedArtists(LoadingActionWith)
+ }
+
+ @Dependency(\.spotify) var spotify
+
+ init() {}
+
+ var body: some ReducerOf {
+ Reduce { _, action in
+ switch action {
+ case .followedArtists:
+ return .none
+ }
+ }
+ .loadable(\.$followedArtists, action: \.followedArtists) {
+ Reduce(ArtistsFeature.body)
+ } load: { _ in
+ let artists = try await spotify.followedArtists().artists
+ guard artists.items.isEmpty else {
+ return .artists(
+ PaginationFeature
+ .State(
+ selection: artists.items.first!.id,
+ next: artists.cursors.after,
+ elements: artists.items
+ )
+ )
+ }
+ return .empty
+ }
+ }
+}
diff --git a/Examples/Spotify/App/SignedIn/SignedInView.swift b/Examples/Spotify/App/SignedIn/SignedInView.swift
new file mode 100644
index 00000000..dd999f8a
--- /dev/null
+++ b/Examples/Spotify/App/SignedIn/SignedInView.swift
@@ -0,0 +1,30 @@
+import ComposableArchitecture
+import ComposableLoadable
+import SwiftUI
+
+struct SignedInView: View {
+ let store: StoreOf
+
+ init(store: StoreOf) {
+ self.store = store
+ }
+
+ var body: some View {
+ NavigationView {
+ contentView
+ }
+ }
+
+ var contentView: some View {
+ LoadableView(
+ loadOnAppear: store.scope(state: \.$followedArtists, action: \.followedArtists)
+ ) { artistsStore in
+ ArtistsView(store: artistsStore)
+ .navigationTitle("Followed Artists")
+ } onError: { error, _ in
+ Text("Error fetching artists: \(error)")
+ } onActive: { _ in
+ ProgressView()
+ }
+ }
+}
diff --git a/Examples/Spotify/App/SignedOut/SignedOutFeature.swift b/Examples/Spotify/App/SignedOut/SignedOutFeature.swift
new file mode 100644
index 00000000..1d11196c
--- /dev/null
+++ b/Examples/Spotify/App/SignedOut/SignedOutFeature.swift
@@ -0,0 +1,44 @@
+import ComposableArchitecture
+import Foundation
+import OAuth
+
+@Reducer
+struct SignedOutFeature {
+
+ @ObservableState
+ enum State {
+ case pending
+ case active
+ case failed(Error)
+ case success
+ }
+
+ enum Action: ViewAction {
+ case signInResponse(TaskResult)
+ case view(View)
+ enum View {
+ case signInButtonTapped
+ }
+ }
+
+ init() {}
+
+ @Dependency(\.spotify) var spotify
+
+ var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .signInResponse(.success):
+ state = .success
+ return .none
+ case let .signInResponse(.failure(error)):
+ state = .failed(error)
+ return .none
+ case .view(.signInButtonTapped):
+ return .run { _ in
+ try await spotify.signIn(nil)
+ }
+ }
+ }
+ }
+}
diff --git a/Examples/Spotify/App/SignedOut/SignedOutView.swift b/Examples/Spotify/App/SignedOut/SignedOutView.swift
new file mode 100644
index 00000000..cab8ac62
--- /dev/null
+++ b/Examples/Spotify/App/SignedOut/SignedOutView.swift
@@ -0,0 +1,17 @@
+import ComposableArchitecture
+import SwiftUI
+
+@ViewAction(for: SignedOutFeature.self)
+struct SignedOutView: View {
+ let store: StoreOf
+
+ init(store: StoreOf) {
+ self.store = store
+ }
+
+ var body: some View {
+ Button("Sign into Spotify") {
+ send(.signInButtonTapped)
+ }
+ }
+}
diff --git a/Examples/Spotify/App/Spotify.entitlements b/Examples/Spotify/App/Spotify.entitlements
new file mode 100644
index 00000000..625af03d
--- /dev/null
+++ b/Examples/Spotify/App/Spotify.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.files.user-selected.read-only
+
+ com.apple.security.network.client
+
+
+
diff --git a/Examples/Spotify/App/SpotifyApp.swift b/Examples/Spotify/App/SpotifyApp.swift
new file mode 100644
index 00000000..eded0f51
--- /dev/null
+++ b/Examples/Spotify/App/SpotifyApp.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+@main
+struct SpotifyApp: App {
+ var body: some Scene {
+ WindowGroup {
+ AppFeatureView()
+ }
+ }
+}
diff --git a/Examples/Spotify/App/SpotifyClient/API/Artist.swift b/Examples/Spotify/App/SpotifyClient/API/Artist.swift
new file mode 100644
index 00000000..ec38bdf4
--- /dev/null
+++ b/Examples/Spotify/App/SpotifyClient/API/Artist.swift
@@ -0,0 +1,56 @@
+import Foundation
+import Networking
+import Tagged
+
+struct Artist: Sendable, Equatable, Codable, Identifiable {
+ let id: Tagged
+ let name: String
+ let genres: [String]
+ let href: String
+ let images: [Image]
+ let popularity: Int
+ let type: String
+ let uri: String
+}
+
+struct Image: Sendable, Equatable, Codable {
+ let url: URL
+ let height: Int
+ let width: Int
+}
+
+extension Artist: Comparable {
+ static func < (lhs: Self, rhs: Self) -> Bool {
+ lhs.popularity < rhs.popularity
+ }
+}
+
+struct Artists: Sendable, Equatable, Codable {
+ let artists: PagedList
+}
+
+extension Request where Body == Artists {
+ static func followedArtists(after: String? = nil, limit: Int? = nil) -> Self {
+ var http = HTTPRequestData(path: "me/following")
+ http.type = "artist"
+ if let limit {
+ http.limit = "\(limit)"
+ }
+ if let after {
+ http.after = after
+ }
+ return Request(http: http, decoder: Spotify.decoder)
+ }
+}
+
+struct PagedList: Sendable, Equatable, Codable {
+ struct Cursors: Sendable, Equatable, Codable {
+ let before: String?
+ let after: String?
+ }
+ let href: String
+ let limit: Int
+ let total: Int
+ let cursors: Cursors
+ let items: [Item]
+}
diff --git a/Examples/Spotify/App/SpotifyClient/API/User.swift b/Examples/Spotify/App/SpotifyClient/API/User.swift
new file mode 100644
index 00000000..c24dfe27
--- /dev/null
+++ b/Examples/Spotify/App/SpotifyClient/API/User.swift
@@ -0,0 +1,15 @@
+import Networking
+
+struct User: Sendable, Equatable, Codable, Identifiable {
+ let displayName: String
+ let email: String
+ let id: String
+ let type: String
+ let uri: String
+}
+
+extension Request where Body == User {
+ static var me: Self {
+ Request(http: HTTPRequestData(path: "me"), decoder: Spotify.decoder)
+ }
+}
diff --git a/Examples/Spotify/App/SpotifyClient/SpotifyClient.swift b/Examples/Spotify/App/SpotifyClient/SpotifyClient.swift
new file mode 100644
index 00000000..1eec78f3
--- /dev/null
+++ b/Examples/Spotify/App/SpotifyClient/SpotifyClient.swift
@@ -0,0 +1,108 @@
+import AuthenticationServices
+import Dependencies
+import DependenciesMacros
+import NetworkClient
+import Networking
+import OAuth
+import os.log
+
+struct Spotify {
+ @DependencyClient
+ struct Client: Sendable {
+ var credentialsDidChange: @Sendable () -> AsyncThrowingStream = {
+ AsyncThrowingStream.never
+ }
+ var followedArtists: @Sendable (_ after: String?, _ limit: Int?) async throws -> Artists
+ var me: @Sendable () async throws -> User
+ var setExistingCredentials: @Sendable (OAuth.AvailableSystems.Spotify.Credentials) async throws -> Void
+ var signIn:
+ @Sendable (_ presentationContext: (any ASWebAuthenticationPresentationContextProviding)?) async throws -> Void
+ var signOut: @Sendable () async throws -> Void
+
+ func followedArtists(after cursor: String? = nil) async throws -> Artists {
+ try await followedArtists(after: cursor, limit: 10)
+ }
+ }
+}
+
+extension DependencyValues {
+ var spotify: Spotify.Client {
+ get { self[Spotify.Client.self] }
+ set { self[Spotify.Client.self] = newValue }
+ }
+}
+
+extension Spotify {
+ static let api: any NetworkingComponent = {
+ @Dependency(\.networkClient.network) var networkClient
+ return networkClient()
+ .logged(using: Logger(subsystem: "works.dan.networking.examples.spotify", category: "Spotify API"))
+ .server(prefixPath: "v1")
+ .server(authority: "api.spotify.com")
+ .authenticated(
+ oauth: .spotify(
+ clientId: "b4937bc99da547b4b90559f5024d8467",
+ callback: "swift-networking-spotify-example://callback",
+ scope: "user-read-email user-read-private user-follow-read"
+ )
+ )
+ }()
+ static let decoder: JSONDecoder = {
+ var decoder = JSONDecoder()
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
+ return decoder
+ }()
+}
+
+extension Spotify.Client: DependencyKey {
+ static let liveValue = Spotify.Client(
+ credentialsDidChange: {
+ AsyncThrowingStream { continuation in
+ Task {
+ do {
+ try await Spotify.api.spotify {
+ await $0.subscribeToCredentialsDidChange { credentials in
+ continuation.yield(credentials)
+ }
+ }
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ }
+ .shared()
+ .eraseToThrowingStream()
+ },
+ followedArtists: { after, limit in
+ try await Spotify.api
+ .value(
+ .followedArtists(
+ after: after,
+ limit: limit
+ )
+ )
+ .body
+ },
+ me: {
+ try await Spotify.api.value(.me).body
+ },
+ setExistingCredentials: { existingCredentials in
+ try await Spotify.api.spotify {
+ await $0.set(credentials: existingCredentials)
+ }
+ },
+ signIn: { context in
+ try await Spotify.api.spotify {
+ if let context {
+ await $0.set(presentationContext: context)
+ }
+ try await $0.signIn()
+ }
+ },
+ signOut: {
+ try await Spotify.api.spotify {
+ await $0.signOut()
+ }
+ }
+ )
+}
diff --git a/Examples/Spotify/App/SpotifyExample/AppFeature.swift b/Examples/Spotify/App/SpotifyExample/AppFeature.swift
new file mode 100644
index 00000000..960d7b75
--- /dev/null
+++ b/Examples/Spotify/App/SpotifyExample/AppFeature.swift
@@ -0,0 +1,106 @@
+import ComposableArchitecture
+import Foundation
+import OAuth
+
+@Reducer
+struct AppFeature {
+
+ @ObservableState
+ enum State {
+ case pending
+ case signedOut(SignedOutFeature.State)
+ case signedIn(SignedInFeature.State)
+ }
+
+ enum Action: ViewAction {
+ case credentialsDidChange(OAuth.AvailableSystems.Spotify.Credentials)
+ case signedInSuccess
+ case signedOutSuccess
+ case signedIn(SignedInFeature.Action)
+ case signedOut(SignedOutFeature.Action)
+ case view(View)
+
+ enum View {
+ case onTask
+ case signOutButtonTapped
+ }
+ }
+
+ // TODO: Store existing Spotify credentials in the Keychain
+ @Shared(.fileStorage(.credentials)) var credentials: OAuth.AvailableSystems.Spotify.Credentials?
+
+ @Dependency(\.spotify) var spotify
+
+ var body: some ReducerOf {
+ Scope(state: \.signedIn, action: \.signedIn) {
+ SignedInFeature()
+ }
+ Scope(state: \.signedOut, action: \.signedOut) {
+ SignedOutFeature()
+ }
+ Reduce { state, action in
+ switch action {
+
+ case let .credentialsDidChange(newCredentials):
+ self.credentials = newCredentials
+ return .none
+
+ case .signedInSuccess:
+ if case .signedIn = state {
+ return .none
+ }
+ state = .signedIn(SignedInFeature.State())
+ return .none
+
+ case .signedOutSuccess:
+ self.credentials = nil
+ state = .signedOut(SignedOutFeature.State.pending)
+ return .none
+
+ case .signedIn:
+ return .none
+
+ case .signedOut:
+ return .none
+
+ case .view(.onTask):
+ let resolvePendingState: Effect
+
+ // Check for existing Spotify credentials
+ if let credentials {
+ resolvePendingState = .run { send in
+ try await spotify.setExistingCredentials(credentials)
+ await send(.signedInSuccess)
+ }
+ } else {
+ state = .signedOut(SignedOutFeature.State.pending)
+ resolvePendingState = .none
+ }
+
+ // Configure long running tasks for the whole app
+ return .merge(
+ resolvePendingState,
+ .run { send in
+ // Subscribe to changes in Spotify credentials, including first sign in success
+ for try await credentials in spotify.credentialsDidChange() {
+ await send(.credentialsDidChange(credentials))
+ await send(.signedInSuccess)
+ }
+ }
+ )
+
+ case .view(.signOutButtonTapped):
+ return .run { send in
+ try await spotify.signOut()
+ await send(.signedOutSuccess)
+ }
+ }
+ }
+ }
+}
+
+extension URL {
+ static let credentials = Self
+ .documentsDirectory
+ .appending(path: "spotify-credentials.json")
+}
diff --git a/Examples/Spotify/App/SpotifyExample/AppFeatureView.swift b/Examples/Spotify/App/SpotifyExample/AppFeatureView.swift
new file mode 100644
index 00000000..284f5df4
--- /dev/null
+++ b/Examples/Spotify/App/SpotifyExample/AppFeatureView.swift
@@ -0,0 +1,47 @@
+import ComposableArchitecture
+import SwiftUI
+
+@ViewAction(for: AppFeature.self)
+struct AppFeatureView {
+ let store: StoreOf
+ init(store: StoreOf) {
+ self.store = store
+ }
+ init() {
+ self.init(
+ store: Store(
+ initialState: .pending
+ ) { AppFeature() }
+ )
+ }
+}
+
+extension AppFeatureView: View {
+ var body: some View {
+ contentView
+ .task { await send(.onTask).finish() }
+ }
+
+ @ViewBuilder
+ private var contentView: some View {
+ switch store.state {
+ case .pending:
+ ProgressView()
+ case .signedIn:
+ if let signedInStore = store.scope(state: \.signedIn, action: \.signedIn) {
+ SignedInView(store: signedInStore)
+ .toolbar {
+ ToolbarItemGroup(placement: .bottomBar) {
+ Button("Sign Out") {
+ send(.signOutButtonTapped)
+ }
+ }
+ }
+ }
+ case .signedOut:
+ if let store = store.scope(state: \.signedOut, action: \.signedOut) {
+ SignedOutView(store: store)
+ }
+ }
+ }
+}
diff --git a/Sources/OAuth/NetworkingComponent+OAuth.swift b/Sources/OAuth/NetworkingComponent+OAuth.swift
index b0e67ec5..7fdd3c1a 100644
--- a/Sources/OAuth/NetworkingComponent+OAuth.swift
+++ b/Sources/OAuth/NetworkingComponent+OAuth.swift
@@ -22,7 +22,8 @@ extension NetworkingComponent {
OAuth.InstalledSystems.set(
oauth: OAuth.Proxy(delegate: delegate)
)
- return authenticated(with: delegate)
+ return server(authenticationMethod: Credentials.method)
+ .authenticated(with: delegate)
}
public func oauth(