From 0cca90ea7115fd524a57613c2247ac215256d21a Mon Sep 17 00:00:00 2001 From: Daniel Thorpe <309420+danthorpe@users.noreply.github.com> Date: Thu, 12 Sep 2024 23:18:24 +0100 Subject: [PATCH] Spotify Example (#101) * feat: Add very basic Spotify demo app * feat: Initial work adding Artists feature * chore: Migrate to a basic Xcode project * feat: Finish Spotify example * chore: remove show sizes * chore: removing unnecessary files * chore: move signout logic to AppFeature * feat: install the authenticationMethod automatically. * chore: remove spurious package modifier --------- Co-authored-by: danthorpe --- .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 212 ++++++++++++++++++ Examples/Spotify/.gitignore | 8 + .../Spotify/App/Artists/ArtistsFeature.swift | 17 ++ .../Spotify/App/Artists/ArtistsView.swift | 72 ++++++ .../SpotifyClient+PaginatingArtists.swift | 15 ++ .../App/SignedIn/SignedInFeature.swift | 56 +++++ .../Spotify/App/SignedIn/SignedInView.swift | 30 +++ .../App/SignedOut/SignedOutFeature.swift | 44 ++++ .../Spotify/App/SignedOut/SignedOutView.swift | 17 ++ Examples/Spotify/App/Spotify.entitlements | 12 + Examples/Spotify/App/SpotifyApp.swift | 10 + .../App/SpotifyClient/API/Artist.swift | 56 +++++ .../Spotify/App/SpotifyClient/API/User.swift | 15 ++ .../App/SpotifyClient/SpotifyClient.swift | 108 +++++++++ .../App/SpotifyExample/AppFeature.swift | 106 +++++++++ .../App/SpotifyExample/AppFeatureView.swift | 47 ++++ Sources/OAuth/NetworkingComponent+OAuth.swift | 3 +- 19 files changed, 842 insertions(+), 1 deletion(-) create mode 100644 Examples/Examples.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/Examples.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Examples/Examples.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Examples/Spotify/.gitignore create mode 100644 Examples/Spotify/App/Artists/ArtistsFeature.swift create mode 100644 Examples/Spotify/App/Artists/ArtistsView.swift create mode 100644 Examples/Spotify/App/Artists/SpotifyClient+PaginatingArtists.swift create mode 100644 Examples/Spotify/App/SignedIn/SignedInFeature.swift create mode 100644 Examples/Spotify/App/SignedIn/SignedInView.swift create mode 100644 Examples/Spotify/App/SignedOut/SignedOutFeature.swift create mode 100644 Examples/Spotify/App/SignedOut/SignedOutView.swift create mode 100644 Examples/Spotify/App/Spotify.entitlements create mode 100644 Examples/Spotify/App/SpotifyApp.swift create mode 100644 Examples/Spotify/App/SpotifyClient/API/Artist.swift create mode 100644 Examples/Spotify/App/SpotifyClient/API/User.swift create mode 100644 Examples/Spotify/App/SpotifyClient/SpotifyClient.swift create mode 100644 Examples/Spotify/App/SpotifyExample/AppFeature.swift create mode 100644 Examples/Spotify/App/SpotifyExample/AppFeatureView.swift 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(