From ac29eb40c62d9e9709d94db897dde990eacf923e Mon Sep 17 00:00:00 2001 From: Ben Kreeger Date: Sun, 23 Nov 2025 21:26:05 -0600 Subject: [PATCH] Add subfeature complete with dependency injection, more or less --- Meow/Package.swift | 41 ++++++++-------- Meow/Sources/CatFactsKit/CatFacts.swift | 4 +- Meow/Sources/Core/CatFacts+Meow.swift | 25 ++++++++++ Meow/Sources/Core/ClientFactory.swift | 31 ++++++++++++ Meow/Sources/Facts/FactsFeature.swift | 64 +++++++++++++++++++++++++ Meow/Sources/Facts/FactsView.swift | 42 ++++++++++++++++ Meow/Sources/Root/RootFeature.swift | 27 ++++++++--- Meow/Sources/Root/RootView.swift | 15 +++--- 8 files changed, 211 insertions(+), 38 deletions(-) create mode 100644 Meow/Sources/Core/CatFacts+Meow.swift create mode 100644 Meow/Sources/Core/ClientFactory.swift create mode 100644 Meow/Sources/Facts/FactsFeature.swift create mode 100644 Meow/Sources/Facts/FactsView.swift diff --git a/Meow/Package.swift b/Meow/Package.swift index d927904..b4ed131 100644 --- a/Meow/Package.swift +++ b/Meow/Package.swift @@ -11,48 +11,45 @@ let commonSwiftSettings: [SwiftSetting] = [ .swiftLanguageMode(.v6) ] +let tcaDependency = Target.Dependency.product(name: "ComposableArchitecture", package: "swift-composable-architecture") + let package = Package( name: "Meow", platforms: [.iOS(.v18), .macOS(.v15)], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "CatFactsKit", - targets: ["CatFactsKit"] - ), - .library( - name: "Root", - targets: ["Root"] - ) + .library(name: "CatFactsKit", targets: ["CatFactsKit"]), + .library(name: "Core", targets: ["Core"]), + .library(name: "Root", targets: ["Root"]), + .library(name: "Facts", targets: ["Facts"]) ], dependencies: [ .package(url: "https://github.com/WeTransfer/Mocker", from: "3.0.2"), .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.23.1"), ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. .target( name: "CatFactsKit", swiftSettings: commonSwiftSettings ), + .target( + name: "Core", + dependencies: ["CatFactsKit", tcaDependency], + swiftSettings: commonSwiftSettings + ), .target( name: "Root", - dependencies: [ - "CatFactsKit", - .product(name: "ComposableArchitecture", package: "swift-composable-architecture") - ], + dependencies: ["CatFactsKit", "Core", "Facts", tcaDependency], + swiftSettings: commonSwiftSettings + ), + .target( + name: "Facts", + dependencies: ["CatFactsKit", "Core", tcaDependency], swiftSettings: commonSwiftSettings ), .testTarget( name: "CatFactsKitTests", - dependencies: [ - "CatFactsKit", - "Mocker" - ], - resources: [ - .process("Fixtures") - ], + dependencies: ["CatFactsKit", "Mocker"], + resources: [.process("Fixtures")], swiftSettings: commonSwiftSettings ), ] diff --git a/Meow/Sources/CatFactsKit/CatFacts.swift b/Meow/Sources/CatFactsKit/CatFacts.swift index faa7608..297e1ad 100644 --- a/Meow/Sources/CatFactsKit/CatFacts.swift +++ b/Meow/Sources/CatFactsKit/CatFacts.swift @@ -5,8 +5,8 @@ import Foundation -public struct CatFacts { - let baseURL: URL +public struct CatFacts: Sendable { + public let baseURL: URL let urlSession: URLSession public init(baseURL: URL, urlSession: URLSession = .shared) { diff --git a/Meow/Sources/Core/CatFacts+Meow.swift b/Meow/Sources/Core/CatFacts+Meow.swift new file mode 100644 index 0000000..68f7b61 --- /dev/null +++ b/Meow/Sources/Core/CatFacts+Meow.swift @@ -0,0 +1,25 @@ +// +// CatFacts+Meow.swift +// Core +// + +import Foundation +import CatFactsKit +import ComposableArchitecture + +extension CatFacts: DependencyKey, TestDependencyKey { + public static var liveValue: CatFactsKit.CatFacts { + CatFacts(baseURL: URL(string: "https://barf.com")!) + } + + public static var testValue: CatFactsKit.CatFacts { + CatFacts(baseURL: URL(string: "https://test.local")!) + } +} + +public extension DependencyValues { + var catFacts: CatFacts { + get { self[CatFacts.self] } + set { self[CatFacts.self] = newValue } + } +} diff --git a/Meow/Sources/Core/ClientFactory.swift b/Meow/Sources/Core/ClientFactory.swift new file mode 100644 index 0000000..04b5f21 --- /dev/null +++ b/Meow/Sources/Core/ClientFactory.swift @@ -0,0 +1,31 @@ +// +// ClientFactory.swift +// Meow +// + +import Foundation +import CatFactsKit +import ComposableArchitecture + +public struct ClientFactory: Sendable { + public func createClient(baseURL: URL) -> CatFacts { + return CatFacts(baseURL: baseURL) + } +} + +extension ClientFactory: DependencyKey, TestDependencyKey { + public static var liveValue: ClientFactory { + ClientFactory() + } + + public static var testValue: ClientFactory { + ClientFactory() + } +} + +public extension DependencyValues { + var clientFactory: ClientFactory { + get { self[ClientFactory.self] } + set { self[ClientFactory.self] = newValue } + } +} diff --git a/Meow/Sources/Facts/FactsFeature.swift b/Meow/Sources/Facts/FactsFeature.swift new file mode 100644 index 0000000..d5e7d23 --- /dev/null +++ b/Meow/Sources/Facts/FactsFeature.swift @@ -0,0 +1,64 @@ +// +// FactsFeature.swift +// Meow +// + +import Foundation +import Core +import CatFactsKit +import ComposableArchitecture + +@Reducer +public struct FactsFeature { + @ObservationIgnored + @Dependency(\.clientFactory) var clientFactory + + @ObservableState + public struct State { + public var baseURL: URL + public var mode: Mode = .notLoaded + + public init(baseURL: URL = URL(string: "https://invalid.barf")!) { + self.baseURL = baseURL + } + } + + public enum Mode { + case notLoaded + case loading + case loaded(facts: [String]) + case error(Error) + } + + public enum Action { + case viewAppeared + case receivedFacts([String]) + case receivedError(Error) + } + + public init() { } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .viewAppeared: + state.mode = .loading + let client = clientFactory.createClient(baseURL: state.baseURL) + return .run { send in + do { + let results = try await client.getFacts(count: 5) + await send(.receivedFacts(results)) + } catch { + await send(.receivedError(error)) + } + } + case let .receivedFacts(facts): + state.mode = .loaded(facts: facts) + return .none + case let .receivedError(error): + state.mode = .error(error) + return .none + } + } + } +} diff --git a/Meow/Sources/Facts/FactsView.swift b/Meow/Sources/Facts/FactsView.swift new file mode 100644 index 0000000..1f94e14 --- /dev/null +++ b/Meow/Sources/Facts/FactsView.swift @@ -0,0 +1,42 @@ +// +// FactsView.swift +// Facts +// + +import SwiftUI +import Foundation +import ComposableArchitecture + +public struct FactsView: View { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack { + switch store.state.mode { + case .notLoaded: + EmptyView() + case .loading: + ProgressView() + case let .loaded(facts): + List(facts, id: \.self) { fact in + Text(fact) + } + case let .error(error): + Text(error.localizedDescription) + } + } + .onAppear { + store.send(.viewAppeared) + } + } +} + +#Preview { + FactsView(store: .init(initialState: FactsFeature.State()) { + FactsFeature() + }) +} diff --git a/Meow/Sources/Root/RootFeature.swift b/Meow/Sources/Root/RootFeature.swift index fa4e3fa..7772221 100644 --- a/Meow/Sources/Root/RootFeature.swift +++ b/Meow/Sources/Root/RootFeature.swift @@ -4,24 +4,39 @@ // import Foundation +import Facts +import CatFactsKit import ComposableArchitecture @Reducer public struct RootFeature { @ObservableState public struct State { - public var baseURL: String? - + var facts: FactsFeature.State = .init(baseURL: URL(string: "https://invalid.barf")!) + public var baseURL: URL? + public init() { } } - + public enum Action { - // Add your actions here + case viewAppeared + case facts(FactsFeature.Action) } - + public init() { } public var body: some ReducerOf { - EmptyReducer() + Reduce { state, action in + switch action { + case .viewAppeared: + state.facts = .init(baseURL: URL(string: "https://meowfacts.herokuapp.com/")!) + return .none + case .facts: + return .none + } + } + Scope(state: \.facts, action: \.facts) { + FactsFeature() + } } } diff --git a/Meow/Sources/Root/RootView.swift b/Meow/Sources/Root/RootView.swift index 30a350e..a7375da 100644 --- a/Meow/Sources/Root/RootView.swift +++ b/Meow/Sources/Root/RootView.swift @@ -4,23 +4,22 @@ // import SwiftUI +import Facts import ComposableArchitecture public struct RootView: View { - let store: StoreOf - + @Bindable var store: StoreOf + public init(store: StoreOf) { self.store = store } public var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") + NavigationStack { + FactsView(store: store.scope(state: \.facts, action: \.facts)) + }.onAppear { + store.send(.viewAppeared) } - .padding() } }