├── .gitignore ├── Samples ├── QuakesUI │ ├── .gitignore │ ├── Package.swift │ └── Sources │ │ └── QuakesUI │ │ ├── Debug.swift │ │ ├── Dispatch.swift │ │ ├── Content.swift │ │ ├── PreviewStore.swift │ │ ├── QuakeUtils.swift │ │ └── StoreKey.swift ├── Services │ ├── .gitignore │ ├── Package.swift │ ├── Sources │ │ └── Services │ │ │ ├── NetworkDataHandler.swift │ │ │ ├── NetworkJSONHandler.swift │ │ │ └── NetworkSession.swift │ └── Tests │ │ └── ServicesTests │ │ ├── TestUtils.swift │ │ ├── NetworkDataHandlerTests.swift │ │ ├── NetworkSessionTests.swift │ │ └── NetworkJSONHandlerTests.swift ├── AnimalsData │ ├── .gitignore │ ├── Resources │ │ ├── AnimalsDataClient │ │ │ └── Info.plist │ │ └── AnimalsDataServer │ │ │ └── Info.plist │ ├── Sources │ │ ├── AnimalsData │ │ │ ├── Status.swift │ │ │ ├── Category.swift │ │ │ ├── AnimalsFilter.swift │ │ │ ├── Animal.swift │ │ │ └── AnimalsAction.swift │ │ ├── AnimalsDataClient │ │ │ └── main.swift │ │ └── AnimalsDataServer │ │ │ └── main.swift │ ├── Tests │ │ └── AnimalsDataTests │ │ │ └── TestUtils.swift │ └── Package.swift ├── AnimalsUI │ ├── .gitignore │ ├── Package.swift │ └── Sources │ │ └── AnimalsUI │ │ ├── Debug.swift │ │ ├── Dispatch.swift │ │ ├── Content.swift │ │ ├── PreviewStore.swift │ │ ├── StoreKey.swift │ │ ├── CategoryList.swift │ │ ├── AnimalDetail.swift │ │ └── AnimalList.swift ├── CounterData │ ├── .gitignore │ ├── Package.swift │ ├── Sources │ │ └── CounterData │ │ │ ├── CounterAction.swift │ │ │ ├── CounterState.swift │ │ │ └── CounterReducer.swift │ └── Tests │ │ └── CounterDataTests │ │ ├── CounterStateTests.swift │ │ └── CounterReducerTests.swift ├── CounterUI │ ├── .gitignore │ ├── Package.swift │ └── Sources │ │ └── CounterUI │ │ ├── Dispatch.swift │ │ ├── Content.swift │ │ ├── StoreKey.swift │ │ └── Select.swift ├── QuakesData │ ├── .gitignore │ ├── Resources │ │ └── QuakesDataClient │ │ │ └── Info.plist │ ├── Sources │ │ ├── QuakesData │ │ │ ├── Status.swift │ │ │ ├── QuakesFilter.swift │ │ │ ├── QuakesAction.swift │ │ │ ├── Quake.swift │ │ │ ├── RemoteStore.swift │ │ │ ├── QuakesReducer.swift │ │ │ ├── QuakesState.swift │ │ │ └── Listener.swift │ │ └── QuakesDataClient │ │ │ └── main.swift │ ├── Package.swift │ └── Tests │ │ └── QuakesDataTests │ │ ├── TestUtils.swift │ │ ├── QuakesFilterTests.swift │ │ └── RemoteStoreTests.swift ├── Animals │ ├── Animals.xcodeproj │ │ └── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ └── Animals │ │ ├── Animals.entitlements │ │ └── AnimalsApp.swift ├── Counter │ ├── Counter.xcodeproj │ │ └── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ └── Counter │ │ ├── Counter.entitlements │ │ └── CounterApp.swift ├── Quakes │ ├── Quakes.xcodeproj │ │ └── project.xcworkspace │ │ │ └── contents.xcworkspacedata │ └── Quakes │ │ ├── Quakes.entitlements │ │ └── QuakesApp.swift ├── Samples.xcworkspace │ └── contents.xcworkspacedata └── test.sh ├── test.sh ├── .github └── workflows │ ├── tests.yml │ ├── builds.yml │ ├── samples-tests.yml │ └── samples-builds.yml ├── Package.swift ├── Sources ├── ImmutableData │ ├── StreamRegistrar.swift │ ├── Selector.swift │ ├── Streamer.swift │ ├── Store.swift │ └── Dispatcher.swift ├── ImmutableUI │ ├── Dispatcher.swift │ ├── Provider.swift │ ├── Listener.swift │ └── AsyncListener.swift └── AsyncSequenceTestUtils │ └── AsyncSequenceTestDouble.swift └── Tests └── AsyncSequenceTestUtilsTests └── AsyncSequenceTestDoubleTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Samples/QuakesUI/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Samples/Services/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Samples/AnimalsData/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Samples/AnimalsUI/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Samples/CounterData/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Samples/CounterUI/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Samples/QuakesData/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Samples/Animals/Animals.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Samples/Counter/Counter.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Samples/Quakes/Quakes.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Samples/Counter/Counter/Counter.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Samples/QuakesData/Resources/QuakesDataClient/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIdentifier 6 | com.northbronson.QuakesDataClient 7 | CFBundleName 8 | QuakesDataClient 9 | 10 | 11 | -------------------------------------------------------------------------------- /Samples/AnimalsData/Resources/AnimalsDataClient/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIdentifier 6 | com.northbronson.AnimalsDataClient 7 | CFBundleName 8 | AnimalsDataClient 9 | 10 | 11 | -------------------------------------------------------------------------------- /Samples/AnimalsData/Resources/AnimalsDataServer/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleIdentifier 6 | com.northbronson.AnimalsDataServer 7 | CFBundleName 8 | AnimalsDataServer 9 | 10 | 11 | -------------------------------------------------------------------------------- /Samples/Quakes/Quakes/Quakes.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Samples/Animals/Animals/Animals.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -euox pipefail 4 | 5 | function package() { 6 | local subcommand=${1} 7 | 8 | swift package reset 9 | swift package resolve 10 | swift ${subcommand} --configuration debug 11 | swift package clean 12 | swift ${subcommand} --configuration release 13 | swift package clean 14 | } 15 | 16 | function main() { 17 | local versions="16.0 16.1 16.2 16.3 16.4 26.0 26.1" 18 | 19 | for version in ${versions}; do 20 | export DEVELOPER_DIR="/Applications/Xcode_${version}.app" 21 | package test 22 | done 23 | } 24 | 25 | main 26 | -------------------------------------------------------------------------------- /Samples/Services/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Services", 7 | platforms: [ 8 | .macOS(.v10_15), 9 | .iOS(.v13), 10 | .tvOS(.v13), 11 | .watchOS(.v6), 12 | .macCatalyst(.v13), 13 | ], 14 | products: [ 15 | .library( 16 | name: "Services", 17 | targets: ["Services"] 18 | ) 19 | ], 20 | targets: [ 21 | .target( 22 | name: "Services" 23 | ), 24 | .testTarget( 25 | name: "ServicesTests", 26 | dependencies: ["Services"] 27 | ), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /Samples/CounterData/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "CounterData", 7 | platforms: [ 8 | .macOS(.v14), 9 | .iOS(.v17), 10 | .tvOS(.v17), 11 | .watchOS(.v10), 12 | .macCatalyst(.v17), 13 | ], 14 | products: [ 15 | .library( 16 | name: "CounterData", 17 | targets: ["CounterData"] 18 | ) 19 | ], 20 | targets: [ 21 | .target( 22 | name: "CounterData" 23 | ), 24 | .testTarget( 25 | name: "CounterDataTests", 26 | dependencies: ["CounterData"] 27 | ), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /Samples/CounterData/Sources/CounterData/CounterAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | public enum CounterAction : Sendable { 18 | case didTapIncrementButton 19 | case didTapDecrementButton 20 | } 21 | -------------------------------------------------------------------------------- /Samples/QuakesData/Sources/QuakesData/Status.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | public enum Status: Hashable, Sendable { 18 | case empty 19 | case waiting 20 | case success 21 | case failure(error: String) 22 | } 23 | -------------------------------------------------------------------------------- /Samples/AnimalsData/Sources/AnimalsData/Status.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | public enum Status: Hashable, Sendable { 18 | case empty 19 | case waiting 20 | case success 21 | case failure(error: String) 22 | } 23 | -------------------------------------------------------------------------------- /Samples/QuakesUI/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "QuakesUI", 7 | platforms: [ 8 | .macOS(.v14), 9 | .iOS(.v17), 10 | .tvOS(.v17), 11 | .watchOS(.v10), 12 | .macCatalyst(.v17), 13 | ], 14 | products: [ 15 | .library( 16 | name: "QuakesUI", 17 | targets: ["QuakesUI"] 18 | ) 19 | ], 20 | dependencies: [ 21 | .package( 22 | name: "QuakesData", 23 | path: "../QuakesData" 24 | ), 25 | .package( 26 | name: "ImmutableData", 27 | path: "../.." 28 | ), 29 | ], 30 | targets: [ 31 | .target( 32 | name: "QuakesUI", 33 | dependencies: [ 34 | "QuakesData", 35 | .product( 36 | name: "ImmutableUI", 37 | package: "ImmutableData" 38 | ), 39 | ] 40 | ) 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /Samples/AnimalsUI/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "AnimalsUI", 7 | platforms: [ 8 | .macOS(.v14), 9 | .iOS(.v17), 10 | .tvOS(.v17), 11 | .watchOS(.v10), 12 | .macCatalyst(.v17), 13 | ], 14 | products: [ 15 | .library( 16 | name: "AnimalsUI", 17 | targets: ["AnimalsUI"] 18 | ) 19 | ], 20 | dependencies: [ 21 | .package( 22 | name: "AnimalsData", 23 | path: "../AnimalsData" 24 | ), 25 | .package( 26 | name: "ImmutableData", 27 | path: "../.." 28 | ), 29 | ], 30 | targets: [ 31 | .target( 32 | name: "AnimalsUI", 33 | dependencies: [ 34 | "AnimalsData", 35 | .product( 36 | name: "ImmutableUI", 37 | package: "ImmutableData" 38 | ), 39 | ] 40 | ) 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /Samples/CounterUI/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "CounterUI", 7 | platforms: [ 8 | .macOS(.v14), 9 | .iOS(.v17), 10 | .tvOS(.v17), 11 | .watchOS(.v10), 12 | .macCatalyst(.v17), 13 | ], 14 | products: [ 15 | .library( 16 | name: "CounterUI", 17 | targets: ["CounterUI"] 18 | ) 19 | ], 20 | dependencies: [ 21 | .package( 22 | name: "CounterData", 23 | path: "../CounterData" 24 | ), 25 | .package( 26 | name: "ImmutableData", 27 | path: "../.." 28 | ), 29 | ], 30 | targets: [ 31 | .target( 32 | name: "CounterUI", 33 | dependencies: [ 34 | "CounterData", 35 | .product( 36 | name: "ImmutableUI", 37 | package: "ImmutableData" 38 | ), 39 | ] 40 | ) 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /Samples/Samples.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 12 | 13 | 15 | 16 | 18 | 19 | 21 | 22 | 24 | 25 | 27 | 28 | 30 | 31 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /Samples/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -euox pipefail 4 | 5 | function package() { 6 | local subcommand=${1} 7 | local package_path=${2} 8 | 9 | swift package --package-path ${package_path} reset 10 | swift package --package-path ${package_path} resolve 11 | swift ${subcommand} --package-path ${package_path} --configuration debug 12 | swift package --package-path ${package_path} clean 13 | swift ${subcommand} --package-path ${package_path} --configuration release 14 | swift package --package-path ${package_path} clean 15 | } 16 | 17 | function main() { 18 | local versions="16.0 16.1 16.2 16.3 16.4 26.0 26.1" 19 | 20 | for version in ${versions}; do 21 | export DEVELOPER_DIR="/Applications/Xcode_${version}.app" 22 | package test AnimalsData 23 | package build AnimalsUI 24 | package test CounterData 25 | package build CounterUI 26 | package test QuakesData 27 | package build QuakesUI 28 | package test Services 29 | done 30 | } 31 | 32 | main 33 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | - "release/**" 7 | push: 8 | branches: 9 | - main 10 | - "release/**" 11 | workflow_dispatch: 12 | permissions: 13 | contents: read 14 | jobs: 15 | macos: 16 | name: Xcode ${{ matrix.xcode-version }} / ${{ matrix.configuration }} 17 | runs-on: macos-15 18 | env: 19 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode-version }}.app 20 | steps: 21 | - name: Run checkout 22 | uses: actions/checkout@v6 23 | - name: Run tests 24 | run: | 25 | swift --version 26 | swift test --configuration ${{ matrix.configuration }} 27 | strategy: 28 | matrix: 29 | xcode-version: 30 | - "16.0" 31 | - "16.1" 32 | - "16.2" 33 | - "16.3" 34 | - "16.4" 35 | - "26.0" 36 | - "26.1" 37 | configuration: 38 | - debug 39 | - release 40 | fail-fast: false 41 | -------------------------------------------------------------------------------- /.github/workflows/builds.yml: -------------------------------------------------------------------------------- 1 | name: Builds 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | - "release/**" 7 | push: 8 | branches: 9 | - main 10 | - "release/**" 11 | workflow_dispatch: 12 | permissions: 13 | contents: read 14 | jobs: 15 | macos: 16 | name: Xcode ${{ matrix.xcode-version }} / ${{ matrix.configuration }} 17 | runs-on: macos-15 18 | env: 19 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode-version }}.app 20 | steps: 21 | - name: Run checkout 22 | uses: actions/checkout@v6 23 | - name: Run builds 24 | run: | 25 | swift --version 26 | swift build --configuration ${{ matrix.configuration }} 27 | strategy: 28 | matrix: 29 | xcode-version: 30 | - "16.0" 31 | - "16.1" 32 | - "16.2" 33 | - "16.3" 34 | - "16.4" 35 | - "26.0" 36 | - "26.1" 37 | configuration: 38 | - debug 39 | - release 40 | fail-fast: false 41 | -------------------------------------------------------------------------------- /Samples/CounterData/Sources/CounterData/CounterState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | public struct CounterState: Sendable { 18 | package var value: Int 19 | 20 | public init(_ value: Int = 0) { 21 | self.value = value 22 | } 23 | } 24 | 25 | extension CounterState { 26 | public static func selectValue() -> @Sendable (Self) -> Int { 27 | { state in state.value } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Samples/QuakesUI/Sources/QuakesUI/Debug.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import SwiftUI 18 | 19 | extension UserDefaults { 20 | fileprivate var isDebug: Bool { 21 | self.bool(forKey: "com.northbronson.QuakesUI.Debug") 22 | } 23 | } 24 | 25 | extension View { 26 | static func debugPrint() { 27 | #if DEBUG 28 | if UserDefaults.standard.isDebug { 29 | self._printChanges() 30 | } 31 | #endif 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Samples/AnimalsUI/Sources/AnimalsUI/Debug.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import SwiftUI 18 | 19 | extension UserDefaults { 20 | fileprivate var isDebug: Bool { 21 | self.bool(forKey: "com.northbronson.AnimalsUI.Debug") 22 | } 23 | } 24 | 25 | extension View { 26 | static func debugPrint() { 27 | #if DEBUG 28 | if UserDefaults.standard.isDebug { 29 | self._printChanges() 30 | } 31 | #endif 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Samples/QuakesUI/Sources/QuakesUI/Dispatch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import ImmutableData 18 | import ImmutableUI 19 | import QuakesData 20 | import SwiftUI 21 | 22 | @MainActor @propertyWrapper struct Dispatch: DynamicProperty { 23 | @ImmutableUI.Dispatcher() private var dispatcher 24 | 25 | init() { 26 | 27 | } 28 | 29 | var wrappedValue: (QuakesAction) throws -> Void { 30 | self.dispatcher.dispatch 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Samples/AnimalsUI/Sources/AnimalsUI/Dispatch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import AnimalsData 18 | import ImmutableData 19 | import ImmutableUI 20 | import SwiftUI 21 | 22 | @MainActor @propertyWrapper struct Dispatch: DynamicProperty { 23 | @ImmutableUI.Dispatcher() private var dispatcher 24 | 25 | init() { 26 | 27 | } 28 | 29 | var wrappedValue: (AnimalsAction) throws -> Void { 30 | self.dispatcher.dispatch 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Samples/CounterUI/Sources/CounterUI/Dispatch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import CounterData 18 | import ImmutableData 19 | import ImmutableUI 20 | import SwiftUI 21 | 22 | @MainActor @propertyWrapper struct Dispatch : DynamicProperty { 23 | @ImmutableUI.Dispatcher() private var dispatcher 24 | 25 | init() { 26 | 27 | } 28 | 29 | var wrappedValue: (CounterAction) throws -> () { 30 | self.dispatcher.dispatch 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Samples/CounterData/Tests/CounterDataTests/CounterStateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import CounterData 18 | import Testing 19 | 20 | @Suite final actor CounterStateTests { 21 | 22 | } 23 | 24 | extension CounterStateTests { 25 | @Test( 26 | arguments: [-1, 0, 1] 27 | ) func selectValue(value: Int) { 28 | let state = CounterState(value) 29 | let value = CounterState.selectValue()(state) 30 | #expect(value == value) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Samples/CounterData/Sources/CounterData/CounterReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | public enum CounterReducer { 18 | @Sendable public static func reduce( 19 | state: CounterState, 20 | action: CounterAction 21 | ) -> CounterState { 22 | switch action { 23 | case .didTapIncrementButton: 24 | var state = state 25 | state.value += 1 26 | return state 27 | case .didTapDecrementButton: 28 | var state = state 29 | state.value -= 1 30 | return state 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Samples/Counter/Counter/CounterApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import CounterData 18 | import CounterUI 19 | import ImmutableData 20 | import ImmutableUI 21 | import SwiftUI 22 | 23 | @main @MainActor struct CounterApp { 24 | @State private var store = Store( 25 | initialState: CounterState(), 26 | reducer: CounterReducer.reduce 27 | ) 28 | } 29 | 30 | extension CounterApp : App { 31 | var body: some Scene { 32 | WindowGroup { 33 | Provider(self.store) { 34 | Content() 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "ImmutableData", 7 | platforms: [ 8 | .macOS(.v14), 9 | .iOS(.v17), 10 | .tvOS(.v17), 11 | .watchOS(.v10), 12 | .macCatalyst(.v17), 13 | ], 14 | products: [ 15 | .library( 16 | name: "AsyncSequenceTestUtils", 17 | targets: ["AsyncSequenceTestUtils"] 18 | ), 19 | .library( 20 | name: "ImmutableData", 21 | targets: ["ImmutableData"] 22 | ), 23 | .library( 24 | name: "ImmutableUI", 25 | targets: ["ImmutableUI"] 26 | ), 27 | ], 28 | targets: [ 29 | .target( 30 | name: "AsyncSequenceTestUtils" 31 | ), 32 | .target( 33 | name: "ImmutableData" 34 | ), 35 | .target( 36 | name: "ImmutableUI", 37 | dependencies: ["ImmutableData"] 38 | ), 39 | .testTarget( 40 | name: "AsyncSequenceTestUtilsTests", 41 | dependencies: ["AsyncSequenceTestUtils"] 42 | ), 43 | .testTarget( 44 | name: "ImmutableDataTests", 45 | dependencies: ["ImmutableData"] 46 | ), 47 | .testTarget( 48 | name: "ImmutableUITests", 49 | dependencies: [ 50 | "AsyncSequenceTestUtils", 51 | "ImmutableUI", 52 | ] 53 | ), 54 | ] 55 | ) 56 | -------------------------------------------------------------------------------- /.github/workflows/samples-tests.yml: -------------------------------------------------------------------------------- 1 | name: Samples Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | - "release/**" 7 | push: 8 | branches: 9 | - main 10 | - "release/**" 11 | workflow_dispatch: 12 | permissions: 13 | contents: read 14 | jobs: 15 | macos: 16 | name: Xcode ${{ matrix.xcode-version }} / ${{ matrix.configuration }} 17 | runs-on: macos-15 18 | env: 19 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode-version }}.app 20 | steps: 21 | - name: Run checkout 22 | uses: actions/checkout@v6 23 | - name: Run tests 24 | run: | 25 | swift --version 26 | swift test --package-path Samples/AnimalsData --configuration ${{ matrix.configuration }} 27 | swift test --package-path Samples/CounterData --configuration ${{ matrix.configuration }} 28 | swift test --package-path Samples/QuakesData --configuration ${{ matrix.configuration }} 29 | swift test --package-path Samples/Services --configuration ${{ matrix.configuration }} 30 | strategy: 31 | matrix: 32 | xcode-version: 33 | - "16.0" 34 | - "16.1" 35 | - "16.2" 36 | - "16.3" 37 | - "16.4" 38 | - "26.0" 39 | - "26.1" 40 | configuration: 41 | - debug 42 | - release 43 | fail-fast: false 44 | -------------------------------------------------------------------------------- /Samples/QuakesData/Sources/QuakesData/QuakesFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | public enum QuakesFilter { 18 | 19 | } 20 | 21 | extension QuakesFilter { 22 | public static func filterQuakes() -> @Sendable (QuakesState, QuakesAction) -> Bool { 23 | { oldState, action in 24 | switch action { 25 | case .ui(.quakeList(.onTapDeleteSelectedQuakeButton)): 26 | return true 27 | case .ui(.quakeList(.onTapDeleteAllQuakesButton)): 28 | return true 29 | case .data(.persistentSession(.localStore(.didFetchQuakes(.success)))): 30 | return true 31 | case .data(.persistentSession(.remoteStore(.didFetchQuakes(.success)))): 32 | return true 33 | default: 34 | return false 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Samples/Services/Sources/Services/NetworkDataHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | package struct NetworkDataHandler { 20 | package static func handle( 21 | _ data: Data, 22 | with response: URLResponse 23 | ) throws -> Data { 24 | guard 25 | let response = (response as? HTTPURLResponse) 26 | else { 27 | throw Error(code: .responseError) 28 | } 29 | guard 30 | 200...299 ~= response.statusCode 31 | else { 32 | throw Error(code: .statusCodeError) 33 | } 34 | return data 35 | } 36 | } 37 | 38 | extension NetworkDataHandler { 39 | package struct Error : Swift.Error { 40 | package enum Code: Equatable { 41 | case responseError 42 | case statusCodeError 43 | } 44 | 45 | package let code: Code 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Samples/CounterData/Tests/CounterDataTests/CounterReducerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import CounterData 18 | import Testing 19 | 20 | @Suite final actor CounterReducerTests { 21 | 22 | } 23 | 24 | extension CounterReducerTests { 25 | @Test( 26 | arguments: [-1, 0, 1] 27 | ) func didTapIncrementButton(value: Int) { 28 | let state = CounterReducer.reduce( 29 | state: CounterState(value), 30 | action: .didTapIncrementButton 31 | ) 32 | #expect(state.value == (value + 1)) 33 | } 34 | } 35 | 36 | extension CounterReducerTests { 37 | @Test( 38 | arguments: [-1, 0, 1] 39 | ) func didTapDecrementButton(value: Int) { 40 | let state = CounterReducer.reduce( 41 | state: CounterState(value), 42 | action: .didTapDecrementButton 43 | ) 44 | #expect(state.value == (value - 1)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Samples/AnimalsUI/Sources/AnimalsUI/Content.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import AnimalsData 18 | import SwiftUI 19 | 20 | @MainActor public struct Content { 21 | @State private var selectedCategoryId: AnimalsData.Category.ID? 22 | @State private var selectedAnimalId: Animal.ID? 23 | 24 | public init() { 25 | 26 | } 27 | } 28 | 29 | extension Content: View { 30 | public var body: some View { 31 | let _ = Self.debugPrint() 32 | NavigationSplitView { 33 | CategoryList(selectedCategoryId: self.$selectedCategoryId) 34 | } content: { 35 | AnimalList( 36 | selectedCategoryId: self.selectedCategoryId, 37 | selectedAnimalId: self.$selectedAnimalId 38 | ) 39 | } detail: { 40 | AnimalDetail(selectedAnimalId: self.selectedAnimalId) 41 | } 42 | } 43 | } 44 | 45 | #Preview { 46 | PreviewStore { 47 | Content() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Samples/Services/Tests/ServicesTests/TestUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | func DataTestDouble() -> Data { 20 | Data(UInt8.min...UInt8.max) 21 | } 22 | 23 | func HTTPURLResponseTestDouble( 24 | statusCode: Int = 200, 25 | headerFields: Dictionary? = nil 26 | ) -> HTTPURLResponse { 27 | HTTPURLResponse( 28 | url: URLTestDouble(), 29 | statusCode: statusCode, 30 | httpVersion: "HTTP/1.1", 31 | headerFields: headerFields 32 | )! 33 | } 34 | 35 | func URLRequestTestDouble() -> URLRequest { 36 | URLRequest(url: URLTestDouble()) 37 | } 38 | 39 | func URLResponseTestDouble() -> URLResponse { 40 | URLResponse( 41 | url: URLTestDouble(), 42 | mimeType: nil, 43 | expectedContentLength: 0, 44 | textEncodingName: nil 45 | ) 46 | } 47 | 48 | func URLTestDouble() -> URL { 49 | URL(string: "https://www.swift.org")! 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/samples-builds.yml: -------------------------------------------------------------------------------- 1 | name: Samples Builds 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | - "release/**" 7 | push: 8 | branches: 9 | - main 10 | - "release/**" 11 | workflow_dispatch: 12 | permissions: 13 | contents: read 14 | jobs: 15 | macos: 16 | name: Xcode ${{ matrix.xcode-version }} / ${{ matrix.configuration }} 17 | runs-on: macos-15 18 | env: 19 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode-version }}.app 20 | steps: 21 | - name: Run checkout 22 | uses: actions/checkout@v6 23 | - name: Run builds 24 | run: | 25 | swift --version 26 | swift build --package-path Samples/AnimalsData --configuration ${{ matrix.configuration }} 27 | swift build --package-path Samples/AnimalsUI --configuration ${{ matrix.configuration }} 28 | swift build --package-path Samples/CounterData --configuration ${{ matrix.configuration }} 29 | swift build --package-path Samples/CounterUI --configuration ${{ matrix.configuration }} 30 | swift build --package-path Samples/QuakesData --configuration ${{ matrix.configuration }} 31 | swift build --package-path Samples/QuakesUI --configuration ${{ matrix.configuration }} 32 | swift build --package-path Samples/Services --configuration ${{ matrix.configuration }} 33 | strategy: 34 | matrix: 35 | xcode-version: 36 | - "16.0" 37 | - "16.1" 38 | - "16.2" 39 | - "16.3" 40 | - "16.4" 41 | - "26.0" 42 | - "26.1" 43 | configuration: 44 | - debug 45 | - release 46 | fail-fast: false 47 | -------------------------------------------------------------------------------- /Samples/AnimalsData/Sources/AnimalsDataClient/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | #if os(macOS) 18 | 19 | import AnimalsData 20 | import Foundation 21 | import Services 22 | 23 | func makeLocalStore() throws -> LocalStore { 24 | if let url = Process().currentDirectoryURL?.appending( 25 | component: "default.store", 26 | directoryHint: .notDirectory 27 | ) { 28 | return try LocalStore(url: url) 29 | } 30 | return try LocalStore() 31 | } 32 | 33 | extension NetworkSession: RemoteStoreNetworkSession { 34 | 35 | } 36 | 37 | func makeRemoteStore() -> RemoteStore> { 38 | let session = NetworkSession(urlSession: URLSession.shared) 39 | return RemoteStore(session: session) 40 | } 41 | 42 | func main() async throws { 43 | let store = makeRemoteStore() 44 | 45 | let animals = try await store.fetchAnimalsQuery() 46 | print(animals) 47 | 48 | let categories = try await store.fetchCategoriesQuery() 49 | print(categories) 50 | } 51 | 52 | try await main() 53 | 54 | #endif 55 | -------------------------------------------------------------------------------- /Samples/QuakesUI/Sources/QuakesUI/Content.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import QuakesData 18 | import SwiftUI 19 | 20 | public struct Content { 21 | @State private var listSelection: Quake.ID? 22 | @State private var mapSelection: Quake.ID? 23 | @State private var searchText: String = "" 24 | @State private var searchDate: Date = .now 25 | 26 | public init() { 27 | 28 | } 29 | } 30 | 31 | extension Content: View { 32 | public var body: some View { 33 | let _ = Self.debugPrint() 34 | NavigationSplitView { 35 | QuakeList( 36 | listSelection: self.$listSelection, 37 | mapSelection: self.mapSelection, 38 | searchText: self.$searchText, 39 | searchDate: self.$searchDate 40 | ) 41 | } detail: { 42 | QuakeMap( 43 | listSelection: self.listSelection, 44 | mapSelection: self.$mapSelection, 45 | searchText: self.searchText, 46 | searchDate: self.searchDate 47 | ) 48 | } 49 | } 50 | } 51 | 52 | #Preview { 53 | PreviewStore { 54 | Content() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Samples/QuakesData/Sources/QuakesDataClient/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | #if os(macOS) 18 | 19 | import Foundation 20 | import QuakesData 21 | import Services 22 | 23 | extension NetworkSession: RemoteStoreNetworkSession { 24 | 25 | } 26 | 27 | func makeLocalStore() throws -> LocalStore { 28 | if let url = Process().currentDirectoryURL?.appending( 29 | component: "default.store", 30 | directoryHint: .notDirectory 31 | ) { 32 | return try LocalStore(url: url) 33 | } 34 | return try LocalStore() 35 | } 36 | 37 | func makeRemoteStore() -> RemoteStore> { 38 | let session = NetworkSession(urlSession: URLSession.shared) 39 | return RemoteStore(session: session) 40 | } 41 | 42 | func main() async throws { 43 | let localStore = try makeLocalStore() 44 | let remoteStore = makeRemoteStore() 45 | 46 | let localQuakes = try await localStore.fetchLocalQuakesQuery() 47 | print(localQuakes) 48 | 49 | let remoteQuakes = try await remoteStore.fetchRemoteQuakesQuery(range: .allHour) 50 | print(remoteQuakes) 51 | } 52 | 53 | try await main() 54 | 55 | #endif 56 | -------------------------------------------------------------------------------- /Samples/QuakesUI/Sources/QuakesUI/PreviewStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import ImmutableData 18 | import ImmutableUI 19 | import QuakesData 20 | import SwiftUI 21 | 22 | @MainActor struct PreviewStore where Content : View { 23 | @State private var store: ImmutableData.Store = { 24 | do { 25 | let store = ImmutableData.Store( 26 | initialState: QuakesState(), 27 | reducer: QuakesReducer.reduce 28 | ) 29 | try store.dispatch( 30 | action: .data( 31 | .persistentSession( 32 | .localStore( 33 | .didFetchQuakes( 34 | result: .success( 35 | quakes: Quake.previewQuakes 36 | ) 37 | ) 38 | ) 39 | ) 40 | ) 41 | ) 42 | return store 43 | } catch { 44 | fatalError("\(error)") 45 | } 46 | }() 47 | private let content: Content 48 | 49 | init(@ViewBuilder content: () -> Content) { 50 | self.content = content() 51 | } 52 | } 53 | 54 | extension PreviewStore: View { 55 | var body: some View { 56 | Provider(self.store) { 57 | self.content 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Samples/AnimalsData/Sources/AnimalsData/Category.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import CowBox 18 | 19 | @CowBox(init: .withPackage) public struct Category: Hashable, Codable, Sendable { 20 | @CowBoxNonMutating public var categoryId: String 21 | @CowBoxNonMutating public var name: String 22 | } 23 | 24 | extension Category: Identifiable { 25 | public var id: String { 26 | self.categoryId 27 | } 28 | } 29 | 30 | extension Category { 31 | public static var amphibian: Self { 32 | Self( 33 | categoryId: "Amphibian", 34 | name: "Amphibian" 35 | ) 36 | } 37 | public static var bird: Self { 38 | Self( 39 | categoryId: "Bird", 40 | name: "Bird" 41 | ) 42 | } 43 | public static var fish: Self { 44 | Self( 45 | categoryId: "Fish", 46 | name: "Fish" 47 | ) 48 | } 49 | public static var invertebrate: Self { 50 | Self( 51 | categoryId: "Invertebrate", 52 | name: "Invertebrate" 53 | ) 54 | } 55 | public static var mammal: Self { 56 | Self( 57 | categoryId: "Mammal", 58 | name: "Mammal" 59 | ) 60 | } 61 | public static var reptile: Self { 62 | Self( 63 | categoryId: "Reptile", 64 | name: "Reptile" 65 | ) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Samples/QuakesUI/Sources/QuakesUI/QuakeUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import CoreLocation 18 | import MapKit 19 | import QuakesData 20 | import SwiftUI 21 | 22 | extension Quake { 23 | static var previewQuakes: Array { 24 | [ 25 | .xxsmall, 26 | .xsmall, 27 | .small, 28 | .medium, 29 | .large, 30 | .xlarge, 31 | .xxlarge, 32 | .xxxlarge 33 | ] 34 | } 35 | } 36 | 37 | extension Quake { 38 | var magnitudeString: String { 39 | self.magnitude.formatted(.number.precision(.fractionLength(1))) 40 | } 41 | } 42 | 43 | extension Quake { 44 | var fullDate: String { 45 | self.time.formatted(date: .complete, time: .complete) 46 | } 47 | } 48 | 49 | extension Quake { 50 | var coordinate: CLLocationCoordinate2D { 51 | CLLocationCoordinate2D( 52 | latitude: self.latitude, 53 | longitude: self.longitude 54 | ) 55 | } 56 | } 57 | 58 | extension Quake { 59 | var color: Color { 60 | switch self.magnitude { 61 | case 0..<1: 62 | return .green 63 | case 1..<2: 64 | return .yellow 65 | case 2..<3: 66 | return .orange 67 | case 3..<5: 68 | return .red 69 | case 5..<7: 70 | return .purple 71 | case 7.. : Sendable where Element : Sendable { 18 | private var count = 0 19 | private var dictionary = Dictionary.Continuation>() 20 | 21 | deinit { 22 | for continuation in self.dictionary.values { 23 | continuation.finish() 24 | } 25 | } 26 | } 27 | 28 | extension StreamRegistrar { 29 | func makeStream() -> AsyncStream { 30 | self.count += 1 31 | return self.makeStream(id: self.count) 32 | } 33 | } 34 | 35 | extension StreamRegistrar { 36 | func yield(_ element: Element) { 37 | for continuation in self.dictionary.values { 38 | continuation.yield(element) 39 | } 40 | } 41 | } 42 | 43 | extension StreamRegistrar { 44 | private func makeStream(id: Int) -> AsyncStream { 45 | let (stream, continuation) = AsyncStream.makeStream(of: Element.self) 46 | continuation.onTermination = { [weak self] termination in 47 | guard let self = self else { return } 48 | Task { 49 | await self.removeContinuation(id: id) 50 | } 51 | } 52 | self.dictionary[id] = continuation 53 | return stream 54 | } 55 | } 56 | 57 | extension StreamRegistrar { 58 | private func removeContinuation(id: Int) { 59 | self.dictionary[id] = nil 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Samples/Animals/Animals/AnimalsApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import AnimalsData 18 | import AnimalsUI 19 | import ImmutableData 20 | import ImmutableUI 21 | import Services 22 | import SwiftUI 23 | 24 | // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0364-retroactive-conformance-warning.md 25 | 26 | @main @MainActor struct AnimalsApp { 27 | @State private var store = Store( 28 | initialState: AnimalsState(), 29 | reducer: AnimalsReducer.reduce 30 | ) 31 | @State private var listener = Listener(store: Self.makeRemoteStore()) 32 | 33 | init() { 34 | self.listener.listen(to: self.store) 35 | } 36 | } 37 | 38 | extension AnimalsApp { 39 | private static func makeLocalStore() -> LocalStore { 40 | do { 41 | return try LocalStore() 42 | } catch { 43 | fatalError("\(error)") 44 | } 45 | } 46 | } 47 | 48 | extension NetworkSession: @retroactive RemoteStoreNetworkSession { 49 | 50 | } 51 | 52 | extension AnimalsApp { 53 | private static func makeRemoteStore() -> RemoteStore> { 54 | let session = NetworkSession(urlSession: URLSession.shared) 55 | return RemoteStore(session: session) 56 | } 57 | } 58 | 59 | extension AnimalsApp: App { 60 | var body: some Scene { 61 | WindowGroup { 62 | Provider(self.store) { 63 | Content() 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Samples/Quakes/Quakes/QuakesApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import ImmutableData 18 | import ImmutableUI 19 | import QuakesData 20 | import QuakesUI 21 | import Services 22 | import SwiftUI 23 | 24 | // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0364-retroactive-conformance-warning.md 25 | 26 | @main @MainActor struct QuakesApp { 27 | @State private var store = Store( 28 | initialState: QuakesState(), 29 | reducer: QuakesReducer.reduce 30 | ) 31 | @State private var listener = Listener( 32 | localStore: Self.makeLocalStore(), 33 | remoteStore: Self.makeRemoteStore() 34 | ) 35 | 36 | init() { 37 | self.listener.listen(to: self.store) 38 | } 39 | } 40 | 41 | extension QuakesApp { 42 | private static func makeLocalStore() -> LocalStore { 43 | do { 44 | return try LocalStore() 45 | } catch { 46 | fatalError("\(error)") 47 | } 48 | } 49 | } 50 | 51 | extension NetworkSession: @retroactive RemoteStoreNetworkSession { 52 | 53 | } 54 | 55 | extension QuakesApp { 56 | private static func makeRemoteStore() -> RemoteStore> { 57 | let session = NetworkSession(urlSession: URLSession.shared) 58 | return RemoteStore(session: session) 59 | } 60 | } 61 | 62 | extension QuakesApp: App { 63 | var body: some Scene { 64 | WindowGroup { 65 | Provider(self.store) { 66 | Content() 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Samples/QuakesData/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "QuakesData", 7 | platforms: [ 8 | .macOS(.v14), 9 | .iOS(.v17), 10 | .tvOS(.v17), 11 | .watchOS(.v10), 12 | .macCatalyst(.v17), 13 | ], 14 | products: [ 15 | .library( 16 | name: "QuakesData", 17 | targets: ["QuakesData"] 18 | ) 19 | ], 20 | dependencies: [ 21 | .package( 22 | url: "https://github.com/apple/swift-algorithms.git", 23 | from: "1.2.0" 24 | ), 25 | .package( 26 | url: "https://github.com/apple/swift-collections.git", 27 | from: "1.1.4" 28 | ), 29 | .package( 30 | url: "https://github.com/swift-cowbox/swift-cowbox.git", 31 | from: "1.0.0" 32 | ), 33 | .package( 34 | name: "ImmutableData", 35 | path: "../.." 36 | ), 37 | .package( 38 | name: "Services", 39 | path: "../Services" 40 | ), 41 | ], 42 | targets: [ 43 | .target( 44 | name: "QuakesData", 45 | dependencies: [ 46 | .product( 47 | name: "Collections", 48 | package: "swift-collections" 49 | ), 50 | .product( 51 | name: "CowBox", 52 | package: "swift-cowbox" 53 | ), 54 | .product( 55 | name: "ImmutableData", 56 | package: "ImmutableData" 57 | ), 58 | ] 59 | ), 60 | .executableTarget( 61 | name: "QuakesDataClient", 62 | dependencies: [ 63 | "QuakesData", 64 | "Services", 65 | ], 66 | linkerSettings: [ 67 | .unsafeFlags([ 68 | "-Xlinker", "-sectcreate", 69 | "-Xlinker", "__TEXT", 70 | "-Xlinker", "__info_plist", 71 | "-Xlinker", "Resources/QuakesDataClient/Info.plist", 72 | ]) 73 | ] 74 | ), 75 | .testTarget( 76 | name: "QuakesDataTests", 77 | dependencies: [ 78 | .product( 79 | name: "Algorithms", 80 | package: "swift-algorithms" 81 | ), 82 | .product( 83 | name: "AsyncSequenceTestUtils", 84 | package: "ImmutableData" 85 | ), 86 | "QuakesData", 87 | ] 88 | ), 89 | ] 90 | ) 91 | -------------------------------------------------------------------------------- /Samples/AnimalsData/Sources/AnimalsData/AnimalsFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | public enum AnimalsFilter { 18 | 19 | } 20 | 21 | extension AnimalsFilter { 22 | public static func filterCategories() -> @Sendable (AnimalsState, AnimalsAction) -> Bool { 23 | { oldState, action in 24 | switch action { 25 | case .data(.persistentSession(.didFetchCategories(result: .success))): 26 | return true 27 | case .data(.persistentSession(.didReloadSampleData(result: .success))): 28 | return true 29 | default: 30 | return false 31 | } 32 | } 33 | } 34 | } 35 | 36 | extension AnimalsFilter { 37 | public static func filterAnimals(categoryId: Category.ID?) -> @Sendable (AnimalsState, AnimalsAction) -> Bool { 38 | { oldState, action in 39 | switch action { 40 | case .data(.persistentSession(.didFetchAnimals(result: .success))): 41 | return true 42 | case .data(.persistentSession(.didReloadSampleData(result: .success))): 43 | return true 44 | case .data(.persistentSession(.didAddAnimal(id: _, result: .success(animal: let animal)))): 45 | return animal.categoryId == categoryId 46 | case .data(.persistentSession(.didDeleteAnimal(animalId: _, result: .success(animal: let animal)))): 47 | return animal.categoryId == categoryId 48 | case .data(.persistentSession(.didUpdateAnimal(animalId: _, result: .success(animal: let animal)))): 49 | return animal.categoryId == categoryId || oldState.animals.data[animal.animalId]?.categoryId == categoryId 50 | default: 51 | return false 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Samples/Services/Tests/ServicesTests/NetworkDataHandlerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Services 18 | import Testing 19 | 20 | @Suite final actor NetworkDataHandlerTestCase { 21 | 22 | } 23 | 24 | extension NetworkDataHandlerTestCase { 25 | static var throwsStatusCodeErrorArguments: Array { 26 | Array(100...199) + Array(300...599) 27 | } 28 | } 29 | 30 | extension NetworkDataHandlerTestCase { 31 | static var noThrowArguments: Array { 32 | Array(200...299) 33 | } 34 | } 35 | 36 | extension NetworkDataHandlerTestCase { 37 | @Test func throwsResponseError() throws { 38 | #expect { 39 | try NetworkDataHandler.handle( 40 | DataTestDouble(), 41 | with: URLResponseTestDouble() 42 | ) 43 | } throws: { error in 44 | let error = try #require(error as? NetworkDataHandler.Error) 45 | return error.code == .responseError 46 | } 47 | } 48 | } 49 | 50 | extension NetworkDataHandlerTestCase { 51 | @Test(arguments: Self.throwsStatusCodeErrorArguments) func throwsStatusCodeError(statusCode: Int) throws { 52 | #expect { 53 | try NetworkDataHandler.handle( 54 | DataTestDouble(), 55 | with: HTTPURLResponseTestDouble(statusCode: statusCode) 56 | ) 57 | } throws: { error in 58 | let error = try #require(error as? NetworkDataHandler.Error) 59 | return error.code == .statusCodeError 60 | } 61 | } 62 | } 63 | 64 | extension NetworkDataHandlerTestCase { 65 | @Test(arguments: Self.noThrowArguments) func noThrow(statusCode: Int) throws { 66 | let data = try NetworkDataHandler.handle( 67 | DataTestDouble(), 68 | with: HTTPURLResponseTestDouble(statusCode: statusCode) 69 | ) 70 | 71 | #expect(data == DataTestDouble()) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/ImmutableData/Selector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | /// An interface that selects a slice of the global state of our application. 18 | public protocol Selector: Sendable { 19 | 20 | /// The global state of our application. 21 | /// 22 | /// - Important: `State` values are modeled as immutable value-types -- *not* mutable reference-types. 23 | /// 24 | /// - SeeAlso: The `State` type serves a similar role as the [State](https://redux.js.org/understanding/thinking-in-redux/glossary#state) type in Redux. 25 | associatedtype State: Sendable 26 | 27 | /// Select a slice of the global state of our application. 28 | /// 29 | /// - Parameter selector: A slice of the global state of our application. 30 | /// 31 | /// - Returns: A slice of the global state of our application. 32 | /// 33 | /// - Important: Selectors are *pure* functions: free of side effects. 34 | /// 35 | /// - SeeAlso: The `selector` function serves a similar role as [Selector](https://redux.js.org/usage/deriving-data-selectors#calculating-derived-data-with-selectors) functions in Redux. 36 | @MainActor func select(_ selector: @Sendable (Self.State) -> T) -> T where T: Sendable 37 | } 38 | 39 | extension Selector { 40 | 41 | /// A convenience function for returning the global state of our application with no transformation applied. 42 | /// 43 | /// For the most part, product engineers should prefer *scoping* the slices of state returned with ``select(_:)``. 44 | /// 45 | /// A component graph that depends on the entire global state of our application could compute its `body` properties more often than necessary. 46 | /// 47 | /// - Returns: The global state of our application. 48 | @available(*, deprecated) 49 | @MainActor public var state: State { 50 | self.select { state in state } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Samples/AnimalsUI/Sources/AnimalsUI/PreviewStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import AnimalsData 18 | import ImmutableData 19 | import ImmutableUI 20 | import SwiftUI 21 | 22 | @MainActor struct PreviewStore where Content : View { 23 | @State private var store: ImmutableData.Store = { 24 | do { 25 | let store = ImmutableData.Store( 26 | initialState: AnimalsState(), 27 | reducer: AnimalsReducer.reduce 28 | ) 29 | try store.dispatch( 30 | action: .data( 31 | .persistentSession( 32 | .didFetchCategories( 33 | result: .success( 34 | categories: [ 35 | .amphibian, 36 | .bird, 37 | .fish, 38 | .invertebrate, 39 | .mammal, 40 | .reptile, 41 | ] 42 | ) 43 | ) 44 | ) 45 | ) 46 | ) 47 | try store.dispatch( 48 | action: .data( 49 | .persistentSession( 50 | .didFetchAnimals( 51 | result: .success( 52 | animals: [ 53 | .dog, 54 | .cat, 55 | .kangaroo, 56 | .gibbon, 57 | .sparrow, 58 | .newt, 59 | ] 60 | ) 61 | ) 62 | ) 63 | ) 64 | ) 65 | return store 66 | } catch { 67 | fatalError("\(error)") 68 | } 69 | }() 70 | private let content: Content 71 | 72 | init(@ViewBuilder content: () -> Content) { 73 | self.content = content() 74 | } 75 | } 76 | 77 | extension PreviewStore: View { 78 | var body: some View { 79 | Provider(self.store) { 80 | self.content 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Samples/AnimalsData/Sources/AnimalsData/Animal.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import CowBox 18 | 19 | @CowBox(init: .withPackage) public struct Animal: Hashable, Codable, Sendable { 20 | @CowBoxNonMutating public var animalId: String 21 | @CowBoxNonMutating public var name: String 22 | @CowBoxNonMutating public var diet: Diet 23 | @CowBoxNonMutating public var categoryId: String 24 | } 25 | 26 | extension Animal { 27 | public enum Diet: String, CaseIterable, Hashable, Codable, Sendable { 28 | case herbivorous = "Herbivore" 29 | case carnivorous = "Carnivore" 30 | case omnivorous = "Omnivore" 31 | } 32 | } 33 | 34 | extension Animal: Identifiable { 35 | public var id: String { 36 | self.animalId 37 | } 38 | } 39 | 40 | extension Animal { 41 | public static var dog: Self { 42 | Self( 43 | animalId: "Dog", 44 | name: "Dog", 45 | diet: .carnivorous, 46 | categoryId: "Mammal" 47 | ) 48 | } 49 | public static var cat: Self { 50 | Self( 51 | animalId: "Cat", 52 | name: "Cat", 53 | diet: .carnivorous, 54 | categoryId: "Mammal" 55 | ) 56 | } 57 | public static var kangaroo: Self { 58 | Self( 59 | animalId: "Kangaroo", 60 | name: "Red kangaroo", 61 | diet: .herbivorous, 62 | categoryId: "Mammal" 63 | ) 64 | } 65 | public static var gibbon: Self { 66 | Self( 67 | animalId: "Bibbon", 68 | name: "Southern gibbon", 69 | diet: .herbivorous, 70 | categoryId: "Mammal" 71 | ) 72 | } 73 | public static var sparrow: Self { 74 | Self( 75 | animalId: "Sparrow", 76 | name: "House sparrow", 77 | diet: .omnivorous, 78 | categoryId: "Bird" 79 | ) 80 | } 81 | public static var newt: Self { 82 | Self( 83 | animalId: "Newt", 84 | name: "Newt", 85 | diet: .carnivorous, 86 | categoryId: "Amphibian" 87 | ) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Samples/AnimalsData/Tests/AnimalsDataTests/TestUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Algorithms 18 | import Collections 19 | 20 | extension Dictionary { 21 | public init(_ values: Value...) where Key == Value.ID, Value : Identifiable { 22 | self.init(values) 23 | } 24 | } 25 | 26 | extension Dictionary { 27 | public init(_ values: S) where Key == Value.ID, Value : Identifiable, S : Sequence, S.Element == Value { 28 | self.init( 29 | uniqueKeysWithValues: values.map { element in 30 | (element.id, element) 31 | } 32 | ) 33 | } 34 | } 35 | 36 | func product( 37 | _ s1: Base1, 38 | _ s2: Base2 39 | ) -> Array<(Base1.Element, Base2.Element)> where Base1 : Sequence, Base2 : Collection { 40 | var result = Array<(Base1.Element, Base2.Element)>() 41 | 42 | let p1 = Algorithms.product(s1, s2) 43 | 44 | for (b1, b2) in p1 { 45 | result.append((b1, b2)) 46 | } 47 | 48 | return result 49 | } 50 | 51 | func product( 52 | _ s1: Base1, 53 | _ s2: Base2, 54 | _ s3: Base3 55 | ) -> Array<(Base1.Element, Base2.Element, Base3.Element)> where Base1 : Sequence, Base2 : Collection, Base3 : Collection { 56 | var result = Array<(Base1.Element, Base2.Element, Base3.Element)>() 57 | 58 | let p1 = Algorithms.product(s1, s2) 59 | let p2 = Algorithms.product(p1, s3) 60 | 61 | for ((b1, b2), b3) in p2 { 62 | result.append((b1, b2, b3)) 63 | } 64 | 65 | return result 66 | } 67 | 68 | extension TreeDictionary { 69 | init(_ values: Value...) where Key == Value.ID, Value : Identifiable { 70 | self.init(values) 71 | } 72 | } 73 | 74 | extension TreeDictionary { 75 | init(_ values: S) where Key == Value.ID, Value : Identifiable, S : Sequence, S.Element == Value { 76 | self.init( 77 | uniqueKeysWithValues: values.map { element in 78 | (element.id, element) 79 | } 80 | ) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Samples/QuakesData/Sources/QuakesData/QuakesAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | public enum QuakesAction: Hashable, Sendable { 18 | case ui(_ action: UI) 19 | case data(_ action: Data) 20 | } 21 | 22 | extension QuakesAction { 23 | public enum UI: Hashable, Sendable { 24 | case quakeList(_ action: QuakeList) 25 | } 26 | } 27 | 28 | extension QuakesAction.UI { 29 | public enum QuakeList: Hashable, Sendable { 30 | case onAppear 31 | case onTapRefreshQuakesButton(range: RefreshQuakesRange) 32 | case onTapDeleteSelectedQuakeButton(quakeId: Quake.ID) 33 | case onTapDeleteAllQuakesButton 34 | } 35 | } 36 | 37 | extension QuakesAction.UI.QuakeList { 38 | public enum RefreshQuakesRange: Hashable, Sendable { 39 | case allHour 40 | case allDay 41 | case allWeek 42 | case allMonth 43 | } 44 | } 45 | 46 | extension QuakesAction { 47 | public enum Data: Hashable, Sendable { 48 | case persistentSession(_ action: PersistentSession) 49 | } 50 | } 51 | 52 | extension QuakesAction.Data { 53 | public enum PersistentSession: Hashable, Sendable { 54 | case localStore(_ action: LocalStore) 55 | case remoteStore(_ action: RemoteStore) 56 | } 57 | } 58 | 59 | extension QuakesAction.Data.PersistentSession { 60 | public enum LocalStore: Hashable, Sendable { 61 | case didFetchQuakes(result: FetchQuakesResult) 62 | } 63 | } 64 | 65 | extension QuakesAction.Data.PersistentSession.LocalStore { 66 | public enum FetchQuakesResult: Hashable, Sendable { 67 | case success(quakes: Array) 68 | case failure(error: String) 69 | } 70 | } 71 | 72 | extension QuakesAction.Data.PersistentSession { 73 | public enum RemoteStore: Hashable, Sendable { 74 | case didFetchQuakes(result: FetchQuakesResult) 75 | } 76 | } 77 | 78 | extension QuakesAction.Data.PersistentSession.RemoteStore { 79 | public enum FetchQuakesResult: Hashable, Sendable { 80 | case success(quakes: Array) 81 | case failure(error: String) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Samples/Services/Sources/Services/NetworkJSONHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | package protocol NetworkJSONHandlerDataHandler { 20 | static func handle( 21 | _ data: Data, 22 | with response: URLResponse 23 | ) throws -> Data 24 | } 25 | 26 | extension NetworkDataHandler: NetworkJSONHandlerDataHandler { 27 | 28 | } 29 | 30 | package protocol NetworkJSONHandlerDecoder { 31 | func decode( 32 | _ type: T.Type, 33 | from data: Data 34 | ) throws -> T where T : Decodable 35 | } 36 | 37 | extension JSONDecoder: NetworkJSONHandlerDecoder { 38 | 39 | } 40 | 41 | package struct NetworkJSONHandler { 42 | package static func handle( 43 | _ data: Data, 44 | with response: URLResponse, 45 | dataHandler: (some NetworkJSONHandlerDataHandler).Type, 46 | from decoder: some NetworkJSONHandlerDecoder 47 | ) throws -> T where T : Decodable { 48 | guard 49 | let mimeType = response.mimeType?.lowercased(), 50 | mimeType == "application/json" 51 | else { 52 | throw Error( 53 | code: .mimeTypeError, 54 | underlying: nil 55 | ) 56 | } 57 | 58 | let data = try { 59 | do { 60 | return try dataHandler.handle( 61 | data, 62 | with: response 63 | ) 64 | } catch { 65 | throw Error( 66 | code: .dataHandlerError, 67 | underlying: error 68 | ) 69 | } 70 | }() 71 | 72 | do { 73 | return try decoder.decode( 74 | T.self, 75 | from: data 76 | ) 77 | } catch { 78 | throw Error( 79 | code: .jsonDecoderError, 80 | underlying: error 81 | ) 82 | } 83 | } 84 | } 85 | 86 | extension NetworkJSONHandler { 87 | package struct Error : Swift.Error { 88 | package enum Code: Equatable { 89 | case mimeTypeError 90 | case dataHandlerError 91 | case jsonDecoderError 92 | } 93 | 94 | package let code: Code 95 | package let underlying: Swift.Error? 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Samples/CounterUI/Sources/CounterUI/Content.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import CounterData 18 | import ImmutableData 19 | import ImmutableUI 20 | import SwiftUI 21 | 22 | // https://developer.apple.com/forums/thread/763442 23 | 24 | @MainActor public struct Content { 25 | @SelectValue private var value 26 | 27 | @Dispatch private var dispatch 28 | 29 | public init() { 30 | 31 | } 32 | } 33 | 34 | extension Content : View { 35 | public var body: some View { 36 | VStack { 37 | Button("Increment") { 38 | self.didTapIncrementButton() 39 | } 40 | Text("Value: \(self.value)") 41 | Button("Decrement") { 42 | self.didTapDecrementButton() 43 | } 44 | } 45 | .frame( 46 | minWidth: 256, 47 | minHeight: 256 48 | ) 49 | } 50 | } 51 | 52 | extension Content { 53 | private func didTapIncrementButton() { 54 | do { 55 | try self.dispatch(.didTapIncrementButton) 56 | } catch { 57 | print(error) 58 | } 59 | } 60 | } 61 | 62 | extension Content { 63 | private func didTapDecrementButton() { 64 | do { 65 | try self.dispatch(.didTapDecrementButton) 66 | } catch { 67 | print(error) 68 | } 69 | } 70 | } 71 | 72 | #Preview { 73 | Content() 74 | } 75 | 76 | #Preview { 77 | @Previewable @State var store = ImmutableData.Store( 78 | initialState: CounterState(), 79 | reducer: CounterReducer.reduce 80 | ) 81 | 82 | Provider(store) { 83 | Content() 84 | } 85 | } 86 | 87 | fileprivate struct CounterError : Swift.Error { 88 | let state: CounterState 89 | let action: CounterAction 90 | } 91 | 92 | #Preview { 93 | @Previewable @State var store = ImmutableData.Store( 94 | initialState: CounterState(), 95 | reducer: { (state: CounterState, action: CounterAction) -> (CounterState) in 96 | throw CounterError( 97 | state: state, 98 | action: action 99 | ) 100 | } 101 | ) 102 | 103 | Provider(store) { 104 | Content() 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/ImmutableData/Streamer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | /// An interface that provides asynchronous updates when events are dispatched. 18 | public protocol Streamer: Sendable { 19 | 20 | /// The global state of our application. 21 | /// 22 | /// - Important: `State` values are modeled as immutable value-types -- *not* mutable reference-types. 23 | /// 24 | /// - SeeAlso: The `State` type serves a similar role as the [State](https://redux.js.org/understanding/thinking-in-redux/glossary#state) type in Redux. 25 | associatedtype State: Sendable 26 | 27 | /// An event that could affect change on the global state of our application. 28 | /// 29 | /// Some examples: 30 | /// * A user tapped on a button component. 31 | /// * A local database returned a set of data models. 32 | /// * A remote server sent a push notification. 33 | /// 34 | /// - Important: `Action` values are modeled as immutable value-types -- *not* mutable reference-types. 35 | /// 36 | /// - SeeAlso: The `Action` type serves a similar role as the [Action](https://redux.js.org/understanding/thinking-in-redux/glossary#action) type in Redux. 37 | associatedtype Action: Sendable 38 | 39 | /// An asynchronous sequence of values. 40 | /// 41 | /// After a ``Dispatcher`` dispatches an event and our root reducer returns a new state value, a new value is added to this list. 42 | /// 43 | /// The `Element` type is equal to `(Self.State, Self.Action)`. 44 | /// 45 | /// The state value is the *previous* state of our system -- *before* an action value was dispatched to our root reducer. 46 | associatedtype Stream: AsyncSequence, Sendable where Self.Stream.Element == (oldState: Self.State, action: Self.Action) 47 | 48 | /// Constructs an asynchronous sequence of values. 49 | /// 50 | /// - Returns: An asynchronous sequence of values. 51 | /// 52 | /// - SeeAlso: The `makeStream()` function serves a similar role as the [`subscribe(listener)`](https://redux.js.org/api/store#subscribelistener) function in Redux and the [Listener](https://redux.js.org/usage/side-effects-approaches#listeners) middleware in Redux Toolkit. 53 | @MainActor func makeStream() -> Self.Stream 54 | } 55 | -------------------------------------------------------------------------------- /Sources/ImmutableUI/Dispatcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import ImmutableData 18 | import SwiftUI 19 | 20 | /// A dynamic property that dispatches events that could affect change on the global state of our application. 21 | /// 22 | /// Product engineers would construct a `Dispatcher` with a key path after a store has been set in this environment: 23 | /// 24 | /// ```swift 25 | /// struct ContentView: View { 26 | /// @ImmutableUI.Selector( 27 | /// \.store, 28 | /// outputSelector: OutputSelector( 29 | /// select: { $0 }, 30 | /// didChange: { $0 != $1 } 31 | /// ) 32 | /// ) var value 33 | /// 34 | /// @ImmutableUI.Dispatcher(\.store) var dispatcher 35 | /// 36 | /// func didTapIncrementButton() { 37 | /// do { 38 | /// try self.dispatcher.dispatch(action: .didTapIncrementButton) 39 | /// } catch { 40 | /// print(error) 41 | /// } 42 | /// } 43 | /// 44 | /// func didTapDecrementButton() { 45 | /// do { 46 | /// try self.dispatcher.dispatch(action: .didTapDecrementButton) 47 | /// } catch { 48 | /// print(error) 49 | /// } 50 | /// } 51 | /// 52 | /// var body: some View { 53 | /// Stepper { 54 | /// Text("Value: \(self.value)") 55 | /// } onIncrement: { 56 | /// self.didTapIncrementButton() 57 | /// } onDecrement: { 58 | /// self.didTapDecrementButton() 59 | /// } 60 | /// } 61 | /// } 62 | /// ``` 63 | /// 64 | /// - Note: `Provider` does not explicitly require its store to be an instance of `ImmutableData.Store`. 65 | /// 66 | /// - SeeAlso: The `Dispatcher` dynamic property serves a similar role as the [`useDispatch`](https://react-redux.js.org/api/hooks#usedispatch) hook from React Redux. 67 | @MainActor @propertyWrapper public struct Dispatcher: DynamicProperty where Store: ImmutableData.Dispatcher { 68 | @Environment private var store: Store 69 | 70 | /// Constructs a `Dispatcher`. 71 | /// 72 | /// - Parameter keyPath: A key path to a store. 73 | public init(_ keyPath: KeyPath) { 74 | self._store = Environment(keyPath) 75 | } 76 | 77 | /// The current store of the environment property. 78 | public var wrappedValue: some ImmutableData.Dispatcher { 79 | self.store 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Samples/AnimalsUI/Sources/AnimalsUI/StoreKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import AnimalsData 18 | import ImmutableData 19 | import ImmutableUI 20 | import SwiftUI 21 | 22 | // https://github.com/apple/swift-evolution/blob/main/proposals/0423-dynamic-actor-isolation.md 23 | 24 | extension ImmutableUI.Provider { 25 | public init( 26 | _ store: Store, 27 | @ViewBuilder content: () -> Content 28 | ) where Store == ImmutableData.Store { 29 | self.init( 30 | \.store, 31 | store, 32 | content: content 33 | ) 34 | } 35 | } 36 | 37 | extension ImmutableUI.Dispatcher { 38 | public init() where Store == ImmutableData.Store { 39 | self.init(\.store) 40 | } 41 | } 42 | 43 | extension ImmutableUI.Selector { 44 | public init( 45 | id: some Hashable, 46 | label: String? = nil, 47 | filter isIncluded: (@Sendable (Store.State, Store.Action) -> Bool)? = nil, 48 | dependencySelector: repeat DependencySelector, 49 | outputSelector: OutputSelector 50 | ) where Store == ImmutableData.Store { 51 | self.init( 52 | \.store, 53 | id: id, 54 | label: label, 55 | filter: isIncluded, 56 | dependencySelector: repeat each dependencySelector, 57 | outputSelector: outputSelector 58 | ) 59 | } 60 | } 61 | 62 | extension ImmutableUI.Selector { 63 | public init( 64 | label: String? = nil, 65 | filter isIncluded: (@Sendable (Store.State, Store.Action) -> Bool)? = nil, 66 | dependencySelector: repeat DependencySelector, 67 | outputSelector: OutputSelector 68 | ) where Store == ImmutableData.Store { 69 | self.init( 70 | \.store, 71 | label: label, 72 | filter: isIncluded, 73 | dependencySelector: repeat each dependencySelector, 74 | outputSelector: outputSelector 75 | ) 76 | } 77 | } 78 | 79 | @MainActor fileprivate struct StoreKey : @preconcurrency EnvironmentKey { 80 | static let defaultValue = ImmutableData.Store( 81 | initialState: AnimalsState(), 82 | reducer: AnimalsReducer.reduce 83 | ) 84 | } 85 | 86 | extension EnvironmentValues { 87 | fileprivate var store: ImmutableData.Store { 88 | get { 89 | self[StoreKey.self] 90 | } 91 | set { 92 | self[StoreKey.self] = newValue 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Samples/CounterUI/Sources/CounterUI/StoreKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import CounterData 18 | import ImmutableData 19 | import ImmutableUI 20 | import SwiftUI 21 | 22 | // https://github.com/apple/swift-evolution/blob/main/proposals/0423-dynamic-actor-isolation.md 23 | 24 | extension ImmutableUI.Provider { 25 | public init( 26 | _ store: Store, 27 | @ViewBuilder content: () -> Content 28 | ) where Store == ImmutableData.Store { 29 | self.init( 30 | \.store, 31 | store, 32 | content: content 33 | ) 34 | } 35 | } 36 | 37 | extension ImmutableUI.Dispatcher { 38 | public init() where Store == ImmutableData.Store { 39 | self.init(\.store) 40 | } 41 | } 42 | 43 | extension ImmutableUI.Selector { 44 | public init( 45 | id: some Hashable, 46 | label: String? = nil, 47 | filter isIncluded: (@Sendable (Store.State, Store.Action) -> Bool)? = nil, 48 | dependencySelector: repeat DependencySelector, 49 | outputSelector: OutputSelector 50 | ) where Store == ImmutableData.Store { 51 | self.init( 52 | \.store, 53 | id: id, 54 | label: label, 55 | filter: isIncluded, 56 | dependencySelector: repeat each dependencySelector, 57 | outputSelector: outputSelector 58 | ) 59 | } 60 | } 61 | 62 | extension ImmutableUI.Selector { 63 | public init( 64 | label: String? = nil, 65 | filter isIncluded: (@Sendable (Store.State, Store.Action) -> Bool)? = nil, 66 | dependencySelector: repeat DependencySelector, 67 | outputSelector: OutputSelector 68 | ) where Store == ImmutableData.Store { 69 | self.init( 70 | \.store, 71 | label: label, 72 | filter: isIncluded, 73 | dependencySelector: repeat each dependencySelector, 74 | outputSelector: outputSelector 75 | ) 76 | } 77 | } 78 | 79 | @MainActor fileprivate struct StoreKey : @preconcurrency EnvironmentKey { 80 | static let defaultValue = ImmutableData.Store( 81 | initialState: CounterState(), 82 | reducer: CounterReducer.reduce 83 | ) 84 | } 85 | 86 | extension EnvironmentValues { 87 | fileprivate var store: ImmutableData.Store { 88 | get { 89 | self[StoreKey.self] 90 | } 91 | set { 92 | self[StoreKey.self] = newValue 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Samples/QuakesUI/Sources/QuakesUI/StoreKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import ImmutableData 18 | import ImmutableUI 19 | import QuakesData 20 | import SwiftUI 21 | 22 | // https://github.com/apple/swift-evolution/blob/main/proposals/0423-dynamic-actor-isolation.md 23 | 24 | // https://developer.apple.com/forums/thread/770298 25 | 26 | extension ImmutableUI.Provider { 27 | public init( 28 | _ store: Store, 29 | @ViewBuilder content: () -> Content 30 | ) where Store == ImmutableData.Store { 31 | self.init( 32 | \.store, 33 | store, 34 | content: content 35 | ) 36 | } 37 | } 38 | 39 | extension ImmutableUI.Dispatcher { 40 | public init() where Store == ImmutableData.Store { 41 | self.init(\.store) 42 | } 43 | } 44 | 45 | extension ImmutableUI.Selector { 46 | public init( 47 | id: some Hashable, 48 | label: String? = nil, 49 | filter isIncluded: (@Sendable (Store.State, Store.Action) -> Bool)? = nil, 50 | dependencySelector: repeat DependencySelector, 51 | outputSelector: OutputSelector 52 | ) where Store == ImmutableData.Store { 53 | self.init( 54 | \.store, 55 | id: id, 56 | label: label, 57 | filter: isIncluded, 58 | dependencySelector: repeat each dependencySelector, 59 | outputSelector: outputSelector 60 | ) 61 | } 62 | } 63 | 64 | extension ImmutableUI.Selector { 65 | public init( 66 | label: String? = nil, 67 | filter isIncluded: (@Sendable (Store.State, Store.Action) -> Bool)? = nil, 68 | dependencySelector: repeat DependencySelector, 69 | outputSelector: OutputSelector 70 | ) where Store == ImmutableData.Store { 71 | self.init( 72 | \.store, 73 | label: label, 74 | filter: isIncluded, 75 | dependencySelector: repeat each dependencySelector, 76 | outputSelector: outputSelector 77 | ) 78 | } 79 | } 80 | 81 | @MainActor fileprivate struct StoreKey : @preconcurrency EnvironmentKey { 82 | static let defaultValue = ImmutableData.Store( 83 | initialState: QuakesState(), 84 | reducer: QuakesReducer.reduce 85 | ) 86 | } 87 | 88 | extension EnvironmentValues { 89 | fileprivate var store: ImmutableData.Store { 90 | get { 91 | self[StoreKey.self] 92 | } 93 | set { 94 | self[StoreKey.self] = newValue 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Samples/AnimalsData/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "AnimalsData", 7 | platforms: [ 8 | .macOS(.v14), 9 | .iOS(.v17), 10 | .tvOS(.v17), 11 | .watchOS(.v10), 12 | .macCatalyst(.v17), 13 | ], 14 | products: [ 15 | .library( 16 | name: "AnimalsData", 17 | targets: ["AnimalsData"] 18 | ) 19 | ], 20 | dependencies: [ 21 | .package( 22 | url: "https://github.com/apple/swift-algorithms.git", 23 | from: "1.2.0" 24 | ), 25 | .package( 26 | url: "https://github.com/apple/swift-async-algorithms.git", 27 | from: "1.0.3" 28 | ), 29 | .package( 30 | url: "https://github.com/apple/swift-collections.git", 31 | from: "1.1.4" 32 | ), 33 | .package( 34 | url: "https://github.com/swift-cowbox/swift-cowbox.git", 35 | from: "1.0.0" 36 | ), 37 | .package( 38 | url: "https://github.com/vapor/vapor.git", 39 | from: "4.111.1" 40 | ), 41 | .package( 42 | name: "ImmutableData", 43 | path: "../.." 44 | ), 45 | .package( 46 | name: "Services", 47 | path: "../Services" 48 | ), 49 | ], 50 | targets: [ 51 | .target( 52 | name: "AnimalsData", 53 | dependencies: [ 54 | .product( 55 | name: "Collections", 56 | package: "swift-collections" 57 | ), 58 | .product( 59 | name: "CowBox", 60 | package: "swift-cowbox" 61 | ), 62 | .product( 63 | name: "ImmutableData", 64 | package: "ImmutableData" 65 | ), 66 | ] 67 | ), 68 | .executableTarget( 69 | name: "AnimalsDataClient", 70 | dependencies: [ 71 | "AnimalsData", 72 | "Services", 73 | ], 74 | linkerSettings: [ 75 | .unsafeFlags([ 76 | "-Xlinker", "-sectcreate", 77 | "-Xlinker", "__TEXT", 78 | "-Xlinker", "__info_plist", 79 | "-Xlinker", "Resources/AnimalsDataClient/Info.plist", 80 | ]) 81 | ] 82 | ), 83 | .executableTarget( 84 | name: "AnimalsDataServer", 85 | dependencies: [ 86 | .product( 87 | name: "AsyncAlgorithms", 88 | package: "swift-async-algorithms" 89 | ), 90 | .product( 91 | name: "Vapor", 92 | package: "vapor", 93 | condition: .when(platforms: [.macOS]) 94 | ), 95 | "AnimalsData", 96 | ], 97 | linkerSettings: [ 98 | .unsafeFlags([ 99 | "-Xlinker", "-sectcreate", 100 | "-Xlinker", "__TEXT", 101 | "-Xlinker", "__info_plist", 102 | "-Xlinker", "Resources/AnimalsDataServer/Info.plist", 103 | ]) 104 | ] 105 | ), 106 | .testTarget( 107 | name: "AnimalsDataTests", 108 | dependencies: [ 109 | .product( 110 | name: "Algorithms", 111 | package: "swift-algorithms" 112 | ), 113 | "AnimalsData", 114 | .product( 115 | name: "AsyncSequenceTestUtils", 116 | package: "ImmutableData" 117 | ), 118 | ] 119 | ), 120 | ] 121 | ) 122 | -------------------------------------------------------------------------------- /Samples/QuakesData/Tests/QuakesDataTests/TestUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Algorithms 18 | import Collections 19 | 20 | extension Dictionary { 21 | public init(_ values: Value...) where Key == Value.ID, Value : Identifiable { 22 | self.init(values) 23 | } 24 | } 25 | 26 | extension Dictionary { 27 | public init(_ values: S) where Key == Value.ID, Value : Identifiable, S : Sequence, S.Element == Value { 28 | self.init( 29 | uniqueKeysWithValues: values.map { element in 30 | (element.id, element) 31 | } 32 | ) 33 | } 34 | } 35 | 36 | func product( 37 | _ s1: Base1, 38 | _ s2: Base2 39 | ) -> Array<(Base1.Element, Base2.Element)> where Base1 : Sequence, Base2 : Collection { 40 | var result = Array<(Base1.Element, Base2.Element)>() 41 | 42 | let p1 = Algorithms.product(s1, s2) 43 | 44 | for (b1, b2) in p1 { 45 | result.append((b1, b2)) 46 | } 47 | 48 | return result 49 | } 50 | 51 | func product( 52 | _ s1: Base1, 53 | _ s2: Base2, 54 | _ s3: Base3 55 | ) -> Array<(Base1.Element, Base2.Element, Base3.Element)> where Base1 : Sequence, Base2 : Collection, Base3 : Collection { 56 | var result = Array<(Base1.Element, Base2.Element, Base3.Element)>() 57 | 58 | let p1 = Algorithms.product(s1, s2) 59 | let p2 = Algorithms.product(p1, s3) 60 | 61 | for ((b1, b2), b3) in p2 { 62 | result.append((b1, b2, b3)) 63 | } 64 | 65 | return result 66 | } 67 | 68 | func product( 69 | _ s1: Base1, 70 | _ s2: Base2, 71 | _ s3: Base3, 72 | _ s4: Base4 73 | ) -> Array<(Base1.Element, Base2.Element, Base3.Element, Base4.Element)> where Base1 : Sequence, Base2 : Collection, Base3 : Collection, Base4 : Collection { 74 | var result = Array<(Base1.Element, Base2.Element, Base3.Element, Base4.Element)>() 75 | 76 | let p1 = Algorithms.product(s1, s2) 77 | let p2 = Algorithms.product(p1, s3) 78 | let p3 = Algorithms.product(p2, s4) 79 | 80 | for (((b1, b2), b3), b4) in p3 { 81 | result.append((b1, b2, b3, b4)) 82 | } 83 | 84 | return result 85 | } 86 | 87 | extension TreeDictionary { 88 | init(_ values: Value...) where Key == Value.ID, Value : Identifiable { 89 | self.init(values) 90 | } 91 | } 92 | 93 | extension TreeDictionary { 94 | init(_ values: S) where Key == Value.ID, Value : Identifiable, S : Sequence, S.Element == Value { 95 | self.init( 96 | uniqueKeysWithValues: values.map { element in 97 | (element.id, element) 98 | } 99 | ) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Samples/Services/Sources/Services/NetworkSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | public protocol NetworkSessionURLSession: Sendable { 20 | func data(for request: URLRequest) async throws -> (Data, URLResponse) 21 | } 22 | 23 | extension URLSession: NetworkSessionURLSession { 24 | 25 | } 26 | 27 | package protocol NetworkSessionJSONHandler { 28 | static func handle( 29 | _ data: Data, 30 | with response: URLResponse, 31 | dataHandler: (some NetworkJSONHandlerDataHandler).Type, 32 | from decoder: some NetworkJSONHandlerDecoder 33 | ) throws -> T where T : Decodable 34 | } 35 | 36 | extension NetworkJSONHandler: NetworkSessionJSONHandler { 37 | 38 | } 39 | 40 | final public class NetworkSession: Sendable where URLSession : NetworkSessionURLSession { 41 | private let urlSession: URLSession 42 | 43 | public init(urlSession: URLSession) { 44 | self.urlSession = urlSession 45 | } 46 | } 47 | 48 | extension NetworkSession { 49 | public func json( 50 | for request: URLRequest, 51 | from decoder: JSONDecoder 52 | ) async throws -> T where T : Decodable { 53 | try await self.json( 54 | for: request, 55 | jsonHandler: NetworkJSONHandler.self, 56 | dataHandler: NetworkDataHandler.self, 57 | from: decoder 58 | ) 59 | } 60 | } 61 | 62 | extension NetworkSession { 63 | package func json( 64 | for request: URLRequest, 65 | jsonHandler: (some NetworkSessionJSONHandler).Type, 66 | dataHandler: (some NetworkJSONHandlerDataHandler).Type, 67 | from decoder: some NetworkJSONHandlerDecoder 68 | ) async throws -> T where T : Decodable { 69 | let (data, response) = try await { 70 | do { 71 | return try await self.urlSession.data(for: request) 72 | } catch { 73 | throw Error( 74 | code: .sessionError, 75 | underlying: error 76 | ) 77 | } 78 | }() 79 | 80 | do { 81 | return try jsonHandler.handle( 82 | data, 83 | with: response, 84 | dataHandler: dataHandler, 85 | from: decoder 86 | ) 87 | } catch { 88 | throw Error( 89 | code: .jsonHandlerError, 90 | underlying: error 91 | ) 92 | } 93 | } 94 | } 95 | 96 | extension NetworkSession { 97 | package struct Error : Swift.Error { 98 | package enum Code: Equatable { 99 | case sessionError 100 | case jsonHandlerError 101 | } 102 | 103 | package let code: Code 104 | package let underlying: Swift.Error? 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/AsyncSequenceTestUtils/AsyncSequenceTestDouble.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0300-continuation.md 18 | 19 | final public actor AsyncSequenceTestDouble : AsyncSequence where Element : Sendable { 20 | public let iterator: Iterator 21 | 22 | public init() { 23 | self.iterator = Iterator() 24 | } 25 | } 26 | 27 | extension AsyncSequenceTestDouble { 28 | public nonisolated func makeAsyncIterator() -> Iterator { 29 | self.iterator 30 | } 31 | } 32 | 33 | extension AsyncSequenceTestDouble { 34 | final public actor Iterator { 35 | private var nextQueue = Array>() 36 | private var sendQueue = Array>() 37 | private var isCancelled = false 38 | } 39 | } 40 | 41 | extension AsyncSequenceTestDouble.Iterator: AsyncIteratorProtocol { 42 | public func next() async throws -> Element? { 43 | if self.sendQueue.isEmpty == false { 44 | self.sendQueue.removeFirst().resume() 45 | } 46 | if Task.isCancelled || self.isCancelled { 47 | return nil 48 | } 49 | return await withTaskCancellationHandler { 50 | let value = await withCheckedContinuation { continuation in 51 | self.nextQueue.append(continuation) 52 | if self.sendQueue.isEmpty == false { 53 | self.sendQueue.removeFirst().resume() 54 | } 55 | } 56 | if Task.isCancelled || self.isCancelled { 57 | return nil 58 | } 59 | if let value = value { 60 | return value 61 | } 62 | self.isCancelled = true 63 | defer { 64 | self.sendQueue.removeFirst().resume() 65 | } 66 | return nil 67 | } onCancel: { 68 | Task { 69 | await self.onCancel() 70 | } 71 | } 72 | } 73 | } 74 | 75 | extension AsyncSequenceTestDouble.Iterator { 76 | public func resume(returning element: Element?) async { 77 | if self.isCancelled { 78 | return 79 | } 80 | if self.nextQueue.isEmpty { 81 | await withCheckedContinuation { continuation in 82 | self.sendQueue.append(continuation) 83 | } 84 | } 85 | await withCheckedContinuation { continuation in 86 | self.sendQueue.append(continuation) 87 | self.nextQueue.removeFirst().resume(returning: element) 88 | } 89 | } 90 | } 91 | 92 | extension AsyncSequenceTestDouble.Iterator { 93 | private func onCancel() { 94 | if self.nextQueue.isEmpty == false { 95 | self.nextQueue.removeFirst().resume(returning: nil) 96 | } 97 | if self.sendQueue.isEmpty == false { 98 | self.sendQueue.removeFirst().resume() 99 | } 100 | self.isCancelled = true 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Samples/QuakesData/Sources/QuakesData/Quake.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import CowBox 18 | import Foundation 19 | 20 | @CowBox(init: .withPackage) public struct Quake: Hashable, Sendable { 21 | @CowBoxNonMutating public var quakeId: String 22 | @CowBoxNonMutating public var magnitude: Double 23 | @CowBoxNonMutating public var time: Date 24 | @CowBoxNonMutating public var updated: Date 25 | @CowBoxNonMutating public var name: String 26 | @CowBoxNonMutating public var longitude: Double 27 | @CowBoxNonMutating public var latitude: Double 28 | } 29 | 30 | extension Quake: Identifiable { 31 | public var id: String { 32 | self.quakeId 33 | } 34 | } 35 | 36 | extension Quake { 37 | public static var xxsmall: Self { 38 | Self( 39 | quakeId: "xxsmall", 40 | magnitude: 0.5, 41 | time: .now, 42 | updated: .now, 43 | name: "West of California", 44 | longitude: -125, 45 | latitude: 35 46 | ) 47 | } 48 | public static var xsmall: Self { 49 | Self( 50 | quakeId: "xsmall", 51 | magnitude: 1.5, 52 | time: .now, 53 | updated: .now, 54 | name: "West of California", 55 | longitude: -125, 56 | latitude: 35 57 | ) 58 | } 59 | public static var small: Self { 60 | Self( 61 | quakeId: "small", 62 | magnitude: 2.5, 63 | time: .now, 64 | updated: .now, 65 | name: "West of California", 66 | longitude: -125, 67 | latitude: 35 68 | ) 69 | } 70 | public static var medium: Self { 71 | Self( 72 | quakeId: "medium", 73 | magnitude: 3.5, 74 | time: .now, 75 | updated: .now, 76 | name: "West of California", 77 | longitude: -125, 78 | latitude: 35 79 | ) 80 | } 81 | public static var large: Self { 82 | Self( 83 | quakeId: "large", 84 | magnitude: 4.5, 85 | time: .now, 86 | updated: .now, 87 | name: "West of California", 88 | longitude: -125, 89 | latitude: 35 90 | ) 91 | } 92 | public static var xlarge: Self { 93 | Self( 94 | quakeId: "xlarge", 95 | magnitude: 5.5, 96 | time: .now, 97 | updated: .now, 98 | name: "West of California", 99 | longitude: -125, 100 | latitude: 35 101 | ) 102 | } 103 | public static var xxlarge: Self { 104 | Self( 105 | quakeId: "xxlarge", 106 | magnitude: 6.5, 107 | time: .now, 108 | updated: .now, 109 | name: "West of California", 110 | longitude: -125, 111 | latitude: 35 112 | ) 113 | } 114 | public static var xxxlarge: Self { 115 | Self( 116 | quakeId: "xxxlarge", 117 | magnitude: 7.5, 118 | time: .now, 119 | updated: .now, 120 | name: "West of California", 121 | longitude: -125, 122 | latitude: 35 123 | ) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Sources/ImmutableData/Store.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | /// The “Fundamental Theorem of `ImmutableData`”. 18 | /// 19 | /// A `Reducer` is a pure function free of side effects. 20 | /// 21 | /// *All* transformations on the global state of our application *must* happen through a root reducer. 22 | /// 23 | /// - Throws: An `Error` indicating this `(State, Action)` pair led to a recoverable error and state was not transformed. 24 | /// 25 | /// - SeeAlso: The `Reducer` type serves a similar role as the [Reducer](https://redux.js.org/understanding/thinking-in-redux/glossary#reducer) type in Redux. 26 | public typealias Reducer = @Sendable (State, Action) throws -> State where State: Sendable, Action: Sendable 27 | 28 | /// An object to save the current state of our data models. 29 | /// 30 | /// Construct a `Store` with an initial state and a root reducer: 31 | /// 32 | /// ```swift 33 | /// enum Action { 34 | /// case increment 35 | /// } 36 | /// 37 | /// func reducer(state: Int, action: Action) -> Int { 38 | /// state + 1 39 | /// } 40 | /// 41 | /// let store = Store( 42 | /// initialState: 0, 43 | /// reducer: reducer 44 | /// ) 45 | /// 46 | /// try store.dispatch(action: .increment) 47 | /// 48 | /// print(store.state) 49 | /// // Prints "1" 50 | /// ``` 51 | /// 52 | /// - SeeAlso: The `Store` object serves a similar role as the [Store](https://redux.js.org/understanding/thinking-in-redux/glossary#store) object in Redux. 53 | @MainActor final public class Store where State: Sendable, Action: Sendable { 54 | private let registrar = StreamRegistrar<(oldState: State, action: Action)>() 55 | 56 | private var state: State 57 | private let reducer: Reducer 58 | 59 | /// Constructs a `Store`. 60 | /// 61 | /// - Parameter state: The initial state of our application. 62 | /// - Parameter reducer: The root reducer of our application. 63 | public init( 64 | initialState state: State, 65 | reducer: @escaping Reducer 66 | ) { 67 | self.state = state 68 | self.reducer = reducer 69 | } 70 | } 71 | 72 | extension Store: Dispatcher { 73 | public func dispatch(action: Action) throws { 74 | let oldState = self.state 75 | self.state = try self.reducer(self.state, action) 76 | self.registrar.yield((oldState: oldState, action: action)) 77 | } 78 | 79 | public func dispatch(thunk: @Sendable (Store, Store) throws -> Void) rethrows { 80 | try thunk(self, self) 81 | } 82 | 83 | public func dispatch(thunk: @Sendable (Store, Store) async throws -> Void) async rethrows { 84 | try await thunk(self, self) 85 | } 86 | } 87 | 88 | extension Store: Selector { 89 | public func select(_ selector: @Sendable (State) -> T) -> T where T: Sendable { 90 | selector(self.state) 91 | } 92 | } 93 | 94 | extension Store: Streamer { 95 | public func makeStream() -> AsyncStream<(oldState: State, action: Action)> { 96 | self.registrar.makeStream() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/ImmutableUI/Provider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import SwiftUI 18 | 19 | /// A component for delivering a store through environment. 20 | /// 21 | /// Product engineers would normally construct one `Provider` component on app launch: 22 | /// 23 | /// ```swift 24 | /// enum Action { 25 | /// case didTapIncrementButton 26 | /// case didTapDecrementButton 27 | /// } 28 | /// 29 | /// func reducer(state: Int, action: Action) -> Int { 30 | /// switch action { 31 | /// case .didTapIncrementButton: 32 | /// state + 1 33 | /// case .didTapDecrementButton: 34 | /// state - 1 35 | /// } 36 | /// } 37 | /// 38 | /// @MainActor struct StoreKey: @preconcurrency EnvironmentKey { 39 | /// static let defaultValue = ImmutableData.Store( 40 | /// initialState: 0, 41 | /// reducer: { state, action in 42 | /// fatalError() 43 | /// } 44 | /// ) 45 | /// } 46 | /// 47 | /// extension EnvironmentValues { 48 | /// var store: ImmutableData.Store { 49 | /// get { 50 | /// self[StoreKey.self] 51 | /// } 52 | /// set { 53 | /// self[StoreKey.self] = newValue 54 | /// } 55 | /// } 56 | /// } 57 | /// 58 | /// @main 59 | /// struct MyApp: App { 60 | /// @State var store = Store( 61 | /// initialState: 0, 62 | /// reducer: reducer 63 | /// ) 64 | /// 65 | /// var body: some Scene { 66 | /// WindowGroup { 67 | /// ImmutableUI.Provider( 68 | /// \.store, 69 | /// self.store 70 | /// ) { 71 | /// ContentView() 72 | /// } 73 | /// } 74 | /// } 75 | /// } 76 | /// ``` 77 | /// 78 | /// - Note: `Provider` does not explicitly require its store to be an instance of `ImmutableData.Store`. 79 | /// 80 | /// - SeeAlso: The `Provider` component serves a similar role as the [`Provider`](https://react-redux.js.org/api/provider) component in React Redux. 81 | @MainActor public struct Provider where Content: View { 82 | private let keyPath: WritableKeyPath 83 | private let store: Store 84 | private let content: Content 85 | 86 | /// Constructs a `Provider`. 87 | /// 88 | /// - Parameter keyPath: A key path that indicates the property of the `EnvironmentValues` structure to update. 89 | /// - Parameter store: The store to set in this component’s environment. 90 | /// - Parameter content: A closure that constructs a component. 91 | /// 92 | /// - Note: `Provider` does not explicitly require its store to be an instance of `ImmutableData.Store`. 93 | public init( 94 | _ keyPath: WritableKeyPath, 95 | _ store: Store, 96 | @ViewBuilder content: () -> Content 97 | ) { 98 | self.keyPath = keyPath 99 | self.store = store 100 | self.content = content() 101 | } 102 | } 103 | 104 | extension Provider: View { 105 | public var body: some View { 106 | self.content.environment(self.keyPath, self.store) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/ImmutableData/Dispatcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | /// An interface that dispatches events that could affect change on the global state of our application. 18 | public protocol Dispatcher: Sendable { 19 | 20 | /// The global state of our application. 21 | /// 22 | /// - Important: `State` values are modeled as immutable value-types -- *not* mutable reference-types. 23 | /// 24 | /// - SeeAlso: The `State` type serves a similar role as the [State](https://redux.js.org/understanding/thinking-in-redux/glossary#state) type in Redux. 25 | associatedtype State: Sendable 26 | 27 | /// An event that could affect change on the global state of our application. 28 | /// 29 | /// Some examples: 30 | /// * A user tapped on a button component. 31 | /// * A local database returned a set of data models. 32 | /// * A remote server sent a push notification. 33 | /// 34 | /// 35 | /// - Important: `Action` values are modeled as immutable value-types -- *not* mutable reference-types. 36 | /// 37 | /// - SeeAlso: The `Action` type serves a similar role as the [Action](https://redux.js.org/understanding/thinking-in-redux/glossary#action) type in Redux. 38 | associatedtype Action: Sendable 39 | 40 | /// A ``/ImmutableData/Dispatcher`` type for affecting change on the global state of our application. 41 | /// 42 | /// This type is passed to the `dispatch(thunk:)` functions. 43 | /// 44 | /// This type can be the same type we just dispatched from -- but this is not explicitly required. 45 | associatedtype Dispatcher: ImmutableData.Dispatcher 46 | 47 | /// A ``/ImmutableData/Selector`` type for selecting a slice of the global state of our application. 48 | /// 49 | /// This type is passed to the `dispatch(thunk:)` functions. 50 | /// 51 | /// This type can be the same type we just dispatched from -- but this is not explicitly required. 52 | associatedtype Selector: ImmutableData.Selector 53 | 54 | /// Dispatch an event that could affect change on the global state of our application. 55 | /// 56 | /// 57 | /// - Parameter action: An event that could affect change on the global state of our application. 58 | /// 59 | /// - Throws: An `Error` from the root reducer. 60 | /// 61 | /// - SeeAlso: The `dispatch(action:)` function serves a similar role as the [`dispatch(action)`](https://redux.js.org/api/store#dispatchaction) function in Redux. 62 | @MainActor func dispatch(action: Action) throws 63 | 64 | /// Dispatch a unit of work that could include side effects. 65 | /// 66 | /// - Parameter thunk: A unit of work that could include side effects. 67 | /// 68 | /// - SeeAlso: The `dispatch(thunk:)` functions serve a similar role as the [Thunk](https://redux.js.org/usage/side-effects-approaches#thunks) middleware in Redux. 69 | @MainActor func dispatch(thunk: @Sendable (Self.Dispatcher, Self.Selector) throws -> Void) rethrows 70 | 71 | /// Dispatch a unit of work that could include side effects. 72 | /// 73 | /// - Parameter thunk: A unit of work that could include side effects. 74 | /// 75 | /// - SeeAlso: The `dispatch(thunk:)` functions serve a similar role as the [Thunk](https://redux.js.org/usage/side-effects-approaches#thunks) middleware in Redux. 76 | @MainActor func dispatch(thunk: @Sendable (Self.Dispatcher, Self.Selector) async throws -> Void) async rethrows 77 | } 78 | -------------------------------------------------------------------------------- /Samples/AnimalsData/Sources/AnimalsData/AnimalsAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | public enum AnimalsAction: Hashable, Sendable { 18 | case ui(_ action: UI) 19 | case data(_ action: Data) 20 | } 21 | 22 | extension AnimalsAction { 23 | public enum UI: Hashable, Sendable { 24 | case categoryList(_ action: CategoryList) 25 | case animalList(_ action: AnimalList) 26 | case animalDetail(_ action: AnimalDetail) 27 | case animalEditor(_ action: AnimalEditor) 28 | } 29 | } 30 | 31 | extension AnimalsAction.UI { 32 | public enum CategoryList: Hashable, Sendable { 33 | case onAppear 34 | case onTapReloadSampleDataButton 35 | } 36 | } 37 | 38 | extension AnimalsAction.UI { 39 | public enum AnimalList: Hashable, Sendable { 40 | case onAppear 41 | case onTapDeleteSelectedAnimalButton(animalId: Animal.ID) 42 | } 43 | } 44 | 45 | extension AnimalsAction.UI { 46 | public enum AnimalDetail: Hashable, Sendable { 47 | case onTapDeleteSelectedAnimalButton(animalId: Animal.ID) 48 | } 49 | } 50 | 51 | extension AnimalsAction.UI { 52 | public enum AnimalEditor: Hashable, Sendable { 53 | case onTapAddAnimalButton( 54 | id: Animal.ID, 55 | name: String, 56 | diet: Animal.Diet, 57 | categoryId: Category.ID 58 | ) 59 | case onTapUpdateAnimalButton( 60 | animalId: Animal.ID, 61 | name: String, 62 | diet: Animal.Diet, 63 | categoryId: Category.ID 64 | ) 65 | } 66 | } 67 | 68 | extension AnimalsAction { 69 | public enum Data: Hashable, Sendable { 70 | case persistentSession(_ action: PersistentSession) 71 | } 72 | } 73 | 74 | extension AnimalsAction.Data { 75 | public enum PersistentSession: Hashable, Sendable { 76 | case didFetchCategories(result: FetchCategoriesResult) 77 | case didFetchAnimals(result: FetchAnimalsResult) 78 | case didReloadSampleData(result: ReloadSampleDataResult) 79 | case didAddAnimal( 80 | id: Animal.ID, 81 | result: AddAnimalResult 82 | ) 83 | case didUpdateAnimal( 84 | animalId: Animal.ID, 85 | result: UpdateAnimalResult 86 | ) 87 | case didDeleteAnimal( 88 | animalId: Animal.ID, 89 | result: DeleteAnimalResult 90 | ) 91 | } 92 | } 93 | 94 | extension AnimalsAction.Data.PersistentSession { 95 | public enum FetchCategoriesResult: Hashable, Sendable { 96 | case success(categories: Array) 97 | case failure(error: String) 98 | } 99 | } 100 | 101 | extension AnimalsAction.Data.PersistentSession { 102 | public enum FetchAnimalsResult: Hashable, Sendable { 103 | case success(animals: Array) 104 | case failure(error: String) 105 | } 106 | } 107 | 108 | extension AnimalsAction.Data.PersistentSession { 109 | public enum ReloadSampleDataResult: Hashable, Sendable { 110 | case success( 111 | animals: Array, 112 | categories: Array 113 | ) 114 | case failure(error: String) 115 | } 116 | } 117 | 118 | extension AnimalsAction.Data.PersistentSession { 119 | public enum AddAnimalResult: Hashable, Sendable { 120 | case success(animal: Animal) 121 | case failure(error: String) 122 | } 123 | } 124 | 125 | extension AnimalsAction.Data.PersistentSession { 126 | public enum UpdateAnimalResult: Hashable, Sendable { 127 | case success(animal: Animal) 128 | case failure(error: String) 129 | } 130 | } 131 | 132 | extension AnimalsAction.Data.PersistentSession { 133 | public enum DeleteAnimalResult: Hashable, Sendable { 134 | case success(animal: Animal) 135 | case failure(error: String) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Samples/QuakesData/Sources/QuakesData/RemoteStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | 19 | // https://developer.apple.com/forums/thread/770288 20 | 21 | package struct RemoteResponse: Hashable, Codable, Sendable { 22 | private let features: Array 23 | } 24 | 25 | extension RemoteResponse { 26 | fileprivate struct Feature: Hashable, Codable, Sendable { 27 | let properties: Properties 28 | let geometry: Geometry 29 | let id: String 30 | } 31 | } 32 | 33 | extension RemoteResponse.Feature { 34 | struct Properties: Hashable, Codable, Sendable { 35 | // Earthquakes from USGS can have null magnitudes. 36 | // ¯\_(ツ)_/¯ 37 | let mag: Double? 38 | let place: String 39 | let time: Date 40 | let updated: Date 41 | } 42 | } 43 | 44 | extension RemoteResponse.Feature { 45 | struct Geometry: Hashable, Codable, Sendable { 46 | let coordinates: Array 47 | } 48 | } 49 | 50 | extension RemoteResponse.Feature { 51 | var quake: Quake { 52 | Quake( 53 | quakeId: self.id, 54 | magnitude: self.properties.mag ?? 0.0, 55 | time: self.properties.time, 56 | updated: self.properties.updated, 57 | name: self.properties.place, 58 | longitude: self.geometry.coordinates[0], 59 | latitude: self.geometry.coordinates[1] 60 | ) 61 | } 62 | } 63 | 64 | extension RemoteResponse { 65 | fileprivate func quakes() -> Array { 66 | self.features.map { $0.quake } 67 | } 68 | } 69 | 70 | public protocol RemoteStoreNetworkSession: Sendable { 71 | func json( 72 | for request: URLRequest, 73 | from decoder: JSONDecoder 74 | ) async throws -> T where T : Decodable 75 | } 76 | 77 | final public actor RemoteStore: PersistentSessionRemoteStore where NetworkSession : RemoteStoreNetworkSession { 78 | private let session: NetworkSession 79 | 80 | public init(session: NetworkSession) { 81 | self.session = session 82 | } 83 | } 84 | 85 | extension RemoteStore { 86 | private static func url(range: QuakesAction.UI.QuakeList.RefreshQuakesRange) -> URL? { 87 | switch range { 88 | case .allHour: 89 | return URL(string: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson") 90 | case .allDay: 91 | return URL(string: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson") 92 | case .allWeek: 93 | return URL(string: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_week.geojson") 94 | case .allMonth: 95 | return URL(string: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson") 96 | } 97 | } 98 | } 99 | 100 | extension RemoteStore { 101 | package struct Error : Swift.Error { 102 | package enum Code: Equatable { 103 | case urlError 104 | } 105 | 106 | package let code: Self.Code 107 | } 108 | } 109 | 110 | extension RemoteStore { 111 | private static func networkRequest(range: QuakesAction.UI.QuakeList.RefreshQuakesRange) throws -> URLRequest { 112 | guard 113 | let url = Self.url(range: range) 114 | else { 115 | throw Error(code: .urlError) 116 | } 117 | return URLRequest(url: url) 118 | } 119 | } 120 | 121 | extension RemoteStore { 122 | public func fetchRemoteQuakesQuery(range: QuakesAction.UI.QuakeList.RefreshQuakesRange) async throws -> Array { 123 | let networkRequest = try Self.networkRequest(range: range) 124 | let decoder = JSONDecoder() 125 | decoder.dateDecodingStrategy = .millisecondsSince1970 126 | let response: RemoteResponse = try await self.session.json( 127 | for: networkRequest, 128 | from: decoder 129 | ) 130 | return response.quakes() 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Samples/CounterUI/Sources/CounterUI/Select.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import CounterData 18 | import ImmutableData 19 | import ImmutableUI 20 | import SwiftUI 21 | 22 | extension ImmutableUI.DependencySelector { 23 | init(select: @escaping @Sendable (State) -> Dependency) where Dependency : Equatable { 24 | self.init(select: select, didChange: { $0 != $1 }) 25 | } 26 | } 27 | 28 | extension ImmutableUI.OutputSelector { 29 | init(select: @escaping @Sendable (State) -> Output) where Output : Equatable { 30 | self.init(select: select, didChange: { $0 != $1 }) 31 | } 32 | } 33 | 34 | extension ImmutableUI.Selector { 35 | init( 36 | id: some Hashable, 37 | label: String? = nil, 38 | filter isIncluded: (@Sendable (Store.State, Store.Action) -> Bool)? = nil, 39 | dependencySelector: repeat @escaping @Sendable (Store.State) -> each Dependency, 40 | outputSelector: @escaping @Sendable (Store.State) -> Output 41 | ) where Store == ImmutableData.Store, repeat each Dependency : Equatable, Output : Equatable { 42 | self.init( 43 | id: id, 44 | label: label, 45 | filter: isIncluded, 46 | dependencySelector: repeat DependencySelector(select: each dependencySelector), 47 | outputSelector: OutputSelector(select: outputSelector) 48 | ) 49 | } 50 | } 51 | 52 | extension ImmutableUI.Selector { 53 | init( 54 | label: String? = nil, 55 | filter isIncluded: (@Sendable (Store.State, Store.Action) -> Bool)? = nil, 56 | dependencySelector: repeat @escaping @Sendable (Store.State) -> each Dependency, 57 | outputSelector: @escaping @Sendable (Store.State) -> Output 58 | ) where Store == ImmutableData.Store, repeat each Dependency : Equatable, Output : Equatable { 59 | self.init( 60 | label: label, 61 | filter: isIncluded, 62 | dependencySelector: repeat DependencySelector(select: each dependencySelector), 63 | outputSelector: OutputSelector(select: outputSelector) 64 | ) 65 | } 66 | } 67 | 68 | extension ImmutableUI.Selector { 69 | mutating func update( 70 | id: some Hashable, 71 | label: String? = nil, 72 | filter isIncluded: (@Sendable (Store.State, Store.Action) -> Bool)? = nil, 73 | dependencySelector: repeat @escaping @Sendable (Store.State) -> each Dependency, 74 | outputSelector: @escaping @Sendable (Store.State) -> Output 75 | ) where Store == ImmutableData.Store, repeat each Dependency : Equatable, Output : Equatable { 76 | self.update( 77 | id: id, 78 | label: label, 79 | filter: isIncluded, 80 | dependencySelector: repeat DependencySelector(select: each dependencySelector), 81 | outputSelector: OutputSelector(select: outputSelector) 82 | ) 83 | } 84 | } 85 | 86 | extension ImmutableUI.Selector { 87 | mutating func update( 88 | label: String? = nil, 89 | filter isIncluded: (@Sendable (Store.State, Store.Action) -> Bool)? = nil, 90 | dependencySelector: repeat @escaping @Sendable (Store.State) -> each Dependency, 91 | outputSelector: @escaping @Sendable (Store.State) -> Output 92 | ) where Store == ImmutableData.Store, repeat each Dependency : Equatable, Output : Equatable { 93 | self.update( 94 | label: label, 95 | filter: isIncluded, 96 | dependencySelector: repeat DependencySelector(select: each dependencySelector), 97 | outputSelector: OutputSelector(select: outputSelector) 98 | ) 99 | } 100 | } 101 | 102 | @MainActor @propertyWrapper struct SelectValue : DynamicProperty { 103 | @ImmutableUI.Selector(outputSelector: CounterState.selectValue()) var wrappedValue 104 | 105 | init() { 106 | 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Samples/AnimalsData/Sources/AnimalsDataServer/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | #if os(macOS) 18 | 19 | import AnimalsData 20 | import AsyncAlgorithms 21 | import Foundation 22 | import Vapor 23 | 24 | extension Sequence { 25 | public func map(_ transform: @escaping @Sendable (Self.Element) async throws -> Transformed) async rethrows -> Array { 26 | try await self.async.map(transform) 27 | } 28 | } 29 | 30 | extension AsyncSequence { 31 | fileprivate func map(_ transform: @escaping @Sendable (Self.Element) async throws -> Transformed) async rethrows -> Array { 32 | let map: AsyncThrowingMapSequence = self.map(transform) 33 | return try await Array(map) 34 | } 35 | } 36 | 37 | extension LocalStore { 38 | fileprivate func response(request: RemoteRequest) async throws -> RemoteResponse { 39 | RemoteResponse( 40 | query: try await self.response(query: request.query), 41 | mutation: try await self.response(mutation: request.mutation) 42 | ) 43 | } 44 | } 45 | 46 | extension LocalStore { 47 | private func response(query: Array?) async throws -> Array? { 48 | try await query?.map { query in try await self.response(query: query) } 49 | } 50 | } 51 | 52 | extension LocalStore { 53 | private func response(mutation: Array?) async throws -> Array? { 54 | try await mutation?.map { mutation in try await self.response(mutation: mutation) } 55 | } 56 | } 57 | 58 | extension LocalStore { 59 | private func response(query: RemoteRequest.Query) async throws -> RemoteResponse.Query { 60 | switch query { 61 | case .animals: 62 | let animals = try await self.fetchAnimalsQuery() 63 | return .animals(animals: animals) 64 | case .categories: 65 | let categories = try await self.fetchCategoriesQuery() 66 | return .categories(categories: categories) 67 | } 68 | } 69 | } 70 | 71 | extension LocalStore { 72 | private func response(mutation: RemoteRequest.Mutation) async throws -> RemoteResponse.Mutation { 73 | switch mutation { 74 | case .addAnimal(name: let name, diet: let diet, categoryId: let categoryId): 75 | let animal = try await self.addAnimalMutation(name: name, diet: diet, categoryId: categoryId) 76 | return .addAnimal(animal: animal) 77 | case .updateAnimal(animalId: let animalId, name: let name, diet: let diet, categoryId: let categoryId): 78 | let animal = try await self.updateAnimalMutation(animalId: animalId, name: name, diet: diet, categoryId: categoryId) 79 | return .updateAnimal(animal: animal) 80 | case .deleteAnimal(animalId: let animalId): 81 | let animal = try await self.deleteAnimalMutation(animalId: animalId) 82 | return .deleteAnimal(animal: animal) 83 | case .reloadSampleData: 84 | let (animals, categories) = try await self.reloadSampleDataMutation() 85 | return .reloadSampleData(animals: animals, categories: categories) 86 | } 87 | } 88 | } 89 | 90 | func makeLocalStore() throws -> LocalStore { 91 | if let url = Process().currentDirectoryURL?.appending( 92 | component: "default.store", 93 | directoryHint: .notDirectory 94 | ) { 95 | return try LocalStore(url: url) 96 | } 97 | return try LocalStore() 98 | } 99 | 100 | func main() async throws { 101 | let localStore = try makeLocalStore() 102 | let app = try await Application.make(.detect()) 103 | app.post("animals", "api") { request in 104 | let response = Response() 105 | let remoteRequest = try request.content.decode(RemoteRequest.self) 106 | print(remoteRequest) 107 | let remoteResponse = try await localStore.response(request: remoteRequest) 108 | print(remoteResponse) 109 | try response.content.encode(remoteResponse, as: .json) 110 | return response 111 | } 112 | try await app.execute() 113 | try await app.asyncShutdown() 114 | } 115 | 116 | try await main() 117 | 118 | #endif 119 | -------------------------------------------------------------------------------- /Sources/ImmutableUI/Listener.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import ImmutableData 18 | 19 | @MainActor final package class Listener where State : Sendable, Action : Sendable, repeat each Dependency : Sendable, Output : Sendable { 20 | private var id: AnyHashable? 21 | private var label: String? 22 | private var filter: (@Sendable (State, Action) -> Bool)? 23 | private var dependencySelector: (repeat DependencySelector) 24 | private var outputSelector: OutputSelector 25 | 26 | private weak var store: AnyObject? 27 | private var listener: AsyncListener? 28 | private var task: Task? 29 | 30 | package init( 31 | id: some Hashable, 32 | label: String? = nil, 33 | filter isIncluded: (@Sendable (State, Action) -> Bool)? = nil, 34 | dependencySelector: repeat DependencySelector, 35 | outputSelector: OutputSelector 36 | ) { 37 | self.id = AnyHashable(id) 38 | self.label = label 39 | self.filter = isIncluded 40 | self.dependencySelector = (repeat each dependencySelector) 41 | self.outputSelector = outputSelector 42 | } 43 | 44 | package init( 45 | label: String? = nil, 46 | filter isIncluded: (@Sendable (State, Action) -> Bool)? = nil, 47 | dependencySelector: repeat DependencySelector, 48 | outputSelector: OutputSelector 49 | ) { 50 | self.id = nil 51 | self.label = label 52 | self.filter = isIncluded 53 | self.dependencySelector = (repeat each dependencySelector) 54 | self.outputSelector = outputSelector 55 | } 56 | 57 | deinit { 58 | self.task?.cancel() 59 | } 60 | } 61 | 62 | extension Listener { 63 | package var output: Output { 64 | guard let output = self.listener?.output else { fatalError("missing output") } 65 | return output 66 | } 67 | } 68 | 69 | extension Listener { 70 | package func update( 71 | id: some Hashable, 72 | label: String? = nil, 73 | filter isIncluded: (@Sendable (State, Action) -> Bool)? = nil, 74 | dependencySelector: repeat DependencySelector, 75 | outputSelector: OutputSelector 76 | ) { 77 | let id = AnyHashable(id) 78 | if self.id != id { 79 | self.id = id 80 | self.label = label 81 | self.filter = isIncluded 82 | self.dependencySelector = (repeat each dependencySelector) 83 | self.outputSelector = outputSelector 84 | self.store = nil 85 | } 86 | } 87 | } 88 | 89 | extension Listener { 90 | package func update( 91 | label: String? = nil, 92 | filter isIncluded: (@Sendable (State, Action) -> Bool)? = nil, 93 | dependencySelector: repeat DependencySelector, 94 | outputSelector: OutputSelector 95 | ) { 96 | if self.id != nil { 97 | self.id = nil 98 | self.label = label 99 | self.filter = isIncluded 100 | self.dependencySelector = (repeat each dependencySelector) 101 | self.outputSelector = outputSelector 102 | self.store = nil 103 | } 104 | } 105 | } 106 | 107 | extension Listener { 108 | package func listen(to store: some ImmutableData.Selector & ImmutableData.Streamer & AnyObject) { 109 | if self.store !== store { 110 | self.store = store 111 | 112 | let listener = AsyncListener( 113 | label: self.label, 114 | filter: self.filter, 115 | dependencySelector: repeat each self.dependencySelector, 116 | outputSelector: self.outputSelector 117 | ) 118 | listener.update(with: store) 119 | self.listener = listener 120 | 121 | let stream = store.makeStream() 122 | 123 | self.task?.cancel() 124 | self.task = Task { 125 | try await listener.listen( 126 | to: stream, 127 | with: store 128 | ) 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Samples/QuakesData/Sources/QuakesData/QuakesReducer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | public enum QuakesReducer { 18 | @Sendable public static func reduce( 19 | state: QuakesState, 20 | action: QuakesAction 21 | ) throws -> QuakesState { 22 | switch action { 23 | case .ui(.quakeList(action: let action)): 24 | return try self.reduce(state: state, action: action) 25 | case .data(.persistentSession(action: let action)): 26 | return try self.reduce(state: state, action: action) 27 | } 28 | } 29 | } 30 | 31 | extension QuakesReducer { 32 | private static func reduce( 33 | state: QuakesState, 34 | action: QuakesAction.UI.QuakeList 35 | ) throws -> QuakesState { 36 | switch action { 37 | case .onAppear: 38 | return self.onAppear(state: state) 39 | case .onTapRefreshQuakesButton: 40 | return self.onTapRefreshQuakesButton(state: state) 41 | case .onTapDeleteSelectedQuakeButton(quakeId: let quakeId): 42 | return try self.deleteSelectedQuake(state: state, quakeId: quakeId) 43 | case .onTapDeleteAllQuakesButton: 44 | return self.deleteAllQuakes(state: state) 45 | } 46 | } 47 | } 48 | 49 | extension QuakesReducer { 50 | private static func onAppear(state: QuakesState) -> QuakesState { 51 | if state.quakes.status == nil { 52 | var state = state 53 | state.quakes.status = .waiting 54 | return state 55 | } 56 | return state 57 | } 58 | } 59 | 60 | extension QuakesReducer { 61 | private static func onTapRefreshQuakesButton(state: QuakesState) -> QuakesState { 62 | if state.quakes.status != .waiting { 63 | var state = state 64 | state.quakes.status = .waiting 65 | return state 66 | } 67 | return state 68 | } 69 | } 70 | 71 | extension QuakesReducer { 72 | package struct Error: Swift.Error { 73 | package enum Code: Hashable, Sendable { 74 | case quakeNotFound 75 | } 76 | 77 | package let code: Self.Code 78 | } 79 | } 80 | 81 | extension QuakesReducer { 82 | private static func deleteSelectedQuake( 83 | state: QuakesState, 84 | quakeId: Quake.ID 85 | ) throws -> QuakesState { 86 | guard let _ = state.quakes.data[quakeId] else { 87 | throw Error(code: .quakeNotFound) 88 | } 89 | var state = state 90 | state.quakes.data[quakeId] = nil 91 | return state 92 | } 93 | } 94 | 95 | extension QuakesReducer { 96 | private static func deleteAllQuakes(state: QuakesState) -> QuakesState { 97 | var state = state 98 | state.quakes.data = [:] 99 | return state 100 | } 101 | } 102 | 103 | extension QuakesReducer { 104 | private static func reduce( 105 | state: QuakesState, 106 | action: QuakesAction.Data.PersistentSession 107 | ) throws -> QuakesState { 108 | switch action { 109 | case .localStore(.didFetchQuakes(result: let result)): 110 | return self.didFetchQuakes(state: state, result: result) 111 | case .remoteStore(.didFetchQuakes(result: let result)): 112 | return self.didFetchQuakes(state: state, result: result) 113 | } 114 | } 115 | } 116 | 117 | extension QuakesReducer { 118 | private static func didFetchQuakes( 119 | state: QuakesState, 120 | result: QuakesAction.Data.PersistentSession.LocalStore.FetchQuakesResult 121 | ) -> QuakesState { 122 | var state = state 123 | switch result { 124 | case .success(quakes: let quakes): 125 | var data = state.quakes.data 126 | for quake in quakes { 127 | data[quake.id] = quake 128 | } 129 | state.quakes.data = data 130 | state.quakes.status = .success 131 | case .failure(error: let error): 132 | state.quakes.status = .failure(error: error) 133 | } 134 | return state 135 | } 136 | } 137 | 138 | extension QuakesReducer { 139 | private static func didFetchQuakes( 140 | state: QuakesState, 141 | result: QuakesAction.Data.PersistentSession.RemoteStore.FetchQuakesResult 142 | ) -> QuakesState { 143 | var state = state 144 | switch result { 145 | case .success(quakes: let quakes): 146 | var data = state.quakes.data 147 | for quake in quakes { 148 | if .zero < quake.magnitude { 149 | data[quake.id] = quake 150 | } else { 151 | data[quake.id] = nil 152 | } 153 | } 154 | state.quakes.data = data 155 | state.quakes.status = .success 156 | case .failure(error: let error): 157 | state.quakes.status = .failure(error: error) 158 | } 159 | return state 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Samples/QuakesData/Sources/QuakesData/QuakesState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Collections 18 | import CowBox 19 | import Foundation 20 | 21 | @CowBox(init: .withPackage) public struct QuakesState: Hashable, Sendable { 22 | @CowBoxMutating package var quakes: Quakes 23 | } 24 | 25 | extension QuakesState { 26 | public init() { 27 | self.init( 28 | quakes: Quakes() 29 | ) 30 | } 31 | } 32 | 33 | extension QuakesState { 34 | @CowBox(init: .withPackage) package struct Quakes: Hashable, Sendable { 35 | @CowBoxMutating package var data: TreeDictionary = [:] 36 | @CowBoxMutating package var status: Status? = nil 37 | } 38 | } 39 | 40 | extension Quake { 41 | fileprivate static func filter( 42 | searchText: String, 43 | searchDate: Date 44 | ) -> @Sendable (Self) -> Bool { 45 | let calendar = Calendar.autoupdatingCurrent 46 | let start = calendar.startOfDay(for: searchDate) 47 | let end = calendar.date(byAdding: DateComponents(day: 1), to: start) ?? start 48 | let range = start...end 49 | return { quake in 50 | if range.contains(quake.time) { 51 | if searchText.isEmpty { 52 | return true 53 | } 54 | if quake.name.contains(searchText) { 55 | return true 56 | } 57 | } 58 | return false 59 | } 60 | } 61 | } 62 | 63 | extension QuakesState { 64 | fileprivate func selectQuakesValues( 65 | filter isIncluded: (Quake) -> Bool, 66 | sort descriptor: SortDescriptor 67 | ) -> Array { 68 | self.quakes.data.values.filter(isIncluded).sorted(using: descriptor) 69 | } 70 | } 71 | 72 | extension QuakesState { 73 | fileprivate func selectQuakesValues( 74 | searchText: String, 75 | searchDate: Date, 76 | sort keyPath: KeyPath & Sendable, 77 | order: SortOrder = .forward 78 | ) -> Array { 79 | self.selectQuakesValues( 80 | filter: Quake.filter( 81 | searchText: searchText, 82 | searchDate: searchDate 83 | ), 84 | sort: SortDescriptor( 85 | keyPath, 86 | order: order 87 | ) 88 | ) 89 | } 90 | } 91 | 92 | extension QuakesState { 93 | public static func selectQuakesValues( 94 | searchText: String, 95 | searchDate: Date, 96 | sort keyPath: KeyPath & Sendable, 97 | order: SortOrder = .forward 98 | ) -> @Sendable (Self) -> Array { 99 | { state in 100 | state.selectQuakesValues( 101 | searchText: searchText, 102 | searchDate: searchDate, 103 | sort: keyPath, 104 | order: order 105 | ) 106 | } 107 | } 108 | } 109 | 110 | extension QuakesState { 111 | fileprivate func selectQuakes(filter isIncluded: (Quake) -> Bool) -> TreeDictionary { 112 | self.quakes.data.filter { isIncluded($0.value) } 113 | } 114 | } 115 | 116 | extension QuakesState { 117 | fileprivate func selectQuakes( 118 | searchText: String, 119 | searchDate: Date 120 | ) -> TreeDictionary { 121 | self.selectQuakes( 122 | filter: Quake.filter( 123 | searchText: searchText, 124 | searchDate: searchDate 125 | ) 126 | ) 127 | } 128 | } 129 | 130 | extension QuakesState { 131 | public static func selectQuakes( 132 | searchText: String, 133 | searchDate: Date 134 | ) -> @Sendable (Self) -> TreeDictionary { 135 | { state in 136 | state.selectQuakes( 137 | searchText: searchText, 138 | searchDate: searchDate 139 | ) 140 | } 141 | } 142 | } 143 | 144 | extension QuakesState { 145 | fileprivate func selectQuakesCount() -> Int { 146 | self.quakes.data.count 147 | } 148 | } 149 | 150 | extension QuakesState { 151 | public static func selectQuakesCount() -> @Sendable (Self) -> Int { 152 | { state in state.selectQuakesCount() } 153 | } 154 | } 155 | 156 | extension QuakesState { 157 | fileprivate func selectQuakesStatus() -> Status? { 158 | self.quakes.status 159 | } 160 | } 161 | 162 | extension QuakesState { 163 | public static func selectQuakesStatus() -> @Sendable (Self) -> Status? { 164 | { state in state.selectQuakesStatus() } 165 | } 166 | } 167 | 168 | extension QuakesState { 169 | fileprivate func selectQuake(quakeId: Quake.ID?) -> Quake? { 170 | guard 171 | let quakeId = quakeId 172 | else { 173 | return nil 174 | } 175 | return self.quakes.data[quakeId] 176 | } 177 | } 178 | 179 | extension QuakesState { 180 | public static func selectQuake(quakeId: Quake.ID?) -> @Sendable (Self) -> Quake? { 181 | { state in state.selectQuake(quakeId: quakeId) } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Samples/QuakesData/Tests/QuakesDataTests/QuakesFilterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Collections 18 | import Foundation 19 | import QuakesData 20 | import Testing 21 | 22 | @Suite final actor QuakesFilterTests { 23 | private static let state = QuakesState( 24 | quakes: QuakesState.Quakes( 25 | data: TreeDictionary( 26 | Quake( 27 | quakeId: "1", 28 | magnitude: 0.5, 29 | time: Date(timeIntervalSince1970: 1.0), 30 | updated: Date(timeIntervalSince1970: 1.0), 31 | name: "West of California", 32 | longitude: -125, 33 | latitude: 35 34 | ), 35 | Quake( 36 | quakeId: "2", 37 | magnitude: 1.5, 38 | time: Date(timeIntervalSince1970: 1.0), 39 | updated: Date(timeIntervalSince1970: 1.0), 40 | name: "West of California", 41 | longitude: -125, 42 | latitude: 35 43 | ), 44 | Quake( 45 | quakeId: "3", 46 | magnitude: 2.5, 47 | time: Date(timeIntervalSince1970: 1.0), 48 | updated: Date(timeIntervalSince1970: 1.0), 49 | name: "West of California", 50 | longitude: -125, 51 | latitude: 35 52 | ), 53 | Quake( 54 | quakeId: "4", 55 | magnitude: 3.5, 56 | time: Date(timeIntervalSince1970: 1.0), 57 | updated: Date(timeIntervalSince1970: 1.0), 58 | name: "West of California", 59 | longitude: -125, 60 | latitude: 35 61 | ), 62 | Quake( 63 | quakeId: "5", 64 | magnitude: 4.5, 65 | time: Date(timeIntervalSince1970: 1.0), 66 | updated: Date(timeIntervalSince1970: 1.0), 67 | name: "West of California", 68 | longitude: -125, 69 | latitude: 35 70 | ), 71 | Quake( 72 | quakeId: "6", 73 | magnitude: 5.5, 74 | time: Date(timeIntervalSince1970: 1.0), 75 | updated: Date(timeIntervalSince1970: 1.0), 76 | name: "West of California", 77 | longitude: -125, 78 | latitude: 35 79 | ), 80 | Quake( 81 | quakeId: "7", 82 | magnitude: 6.5, 83 | time: Date(timeIntervalSince1970: 1.0), 84 | updated: Date(timeIntervalSince1970: 1.0), 85 | name: "West of California", 86 | longitude: -125, 87 | latitude: 35 88 | ), 89 | Quake( 90 | quakeId: "8", 91 | magnitude: 7.5, 92 | time: Date(timeIntervalSince1970: 1.0), 93 | updated: Date(timeIntervalSince1970: 1.0), 94 | name: "West of California", 95 | longitude: -125, 96 | latitude: 35 97 | ) 98 | ) 99 | ) 100 | ) 101 | } 102 | 103 | extension QuakesFilterTests { 104 | private static var filterCategoriesValuesIncludedArguments: Array { 105 | var array = Array() 106 | array.append( 107 | .ui(.quakeList(.onTapDeleteSelectedQuakeButton(quakeId: "quakeId"))) 108 | ) 109 | array.append( 110 | .ui(.quakeList(.onTapDeleteAllQuakesButton)) 111 | ) 112 | array.append( 113 | .data(.persistentSession(.localStore(.didFetchQuakes(result: .success(quakes: []))))) 114 | ) 115 | array.append( 116 | .data(.persistentSession(.remoteStore(.didFetchQuakes(result: .success(quakes: []))))) 117 | ) 118 | return array 119 | } 120 | } 121 | 122 | extension QuakesFilterTests { 123 | private static var filterCategoriesValuesNotIncludedArguments: Array { 124 | var array = Array() 125 | array.append( 126 | .ui(.quakeList(.onAppear)) 127 | ) 128 | array.append( 129 | .ui(.quakeList(.onTapRefreshQuakesButton(range: .allHour))) 130 | ) 131 | array.append( 132 | .ui(.quakeList(.onTapRefreshQuakesButton(range: .allDay))) 133 | ) 134 | array.append( 135 | .ui(.quakeList(.onTapRefreshQuakesButton(range: .allWeek))) 136 | ) 137 | array.append( 138 | .ui(.quakeList(.onTapRefreshQuakesButton(range: .allMonth))) 139 | ) 140 | return array 141 | } 142 | } 143 | 144 | extension QuakesFilterTests { 145 | @Test(arguments: Self.filterCategoriesValuesIncludedArguments) func filterQuakesIncluded(action: QuakesAction) { 146 | let isIncluded = QuakesFilter.filterQuakes()(Self.state, action) 147 | #expect(isIncluded) 148 | } 149 | } 150 | 151 | extension QuakesFilterTests { 152 | @Test(arguments: Self.filterCategoriesValuesNotIncludedArguments) func filterQuakesNotIncluded(action: QuakesAction) { 153 | let isIncluded = QuakesFilter.filterQuakes()(Self.state, action) 154 | #expect(isIncluded == false) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Samples/AnimalsUI/Sources/AnimalsUI/CategoryList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import AnimalsData 18 | import SwiftUI 19 | 20 | @MainActor struct CategoryList { 21 | @Binding private var selectedCategoryId: AnimalsData.Category.ID? 22 | 23 | init(selectedCategoryId: Binding) { 24 | self._selectedCategoryId = selectedCategoryId 25 | } 26 | } 27 | 28 | extension CategoryList : View { 29 | var body: some View { 30 | let _ = CategoryList.debugPrint() 31 | Container(selectedCategoryId: self.$selectedCategoryId) 32 | } 33 | } 34 | 35 | extension CategoryList { 36 | @MainActor fileprivate struct Container { 37 | @SelectCategoriesValues private var categories: Array 38 | @SelectCategoriesStatus private var status: Status? 39 | 40 | @Binding private var selectedCategoryId: AnimalsData.Category.ID? 41 | 42 | @Dispatch private var dispatch 43 | 44 | init(selectedCategoryId: Binding) { 45 | self._categories = SelectCategoriesValues() 46 | self._status = SelectCategoriesStatus() 47 | 48 | self._selectedCategoryId = selectedCategoryId 49 | } 50 | } 51 | } 52 | 53 | extension CategoryList.Container { 54 | private func onAppear() { 55 | do { 56 | try self.dispatch( 57 | .ui( 58 | .categoryList( 59 | .onAppear 60 | ) 61 | ) 62 | ) 63 | } catch { 64 | print(error) 65 | } 66 | } 67 | } 68 | 69 | extension CategoryList.Container { 70 | private func onReloadSampleData() { 71 | do { 72 | try self.dispatch( 73 | .ui( 74 | .categoryList( 75 | .onTapReloadSampleDataButton 76 | ) 77 | ) 78 | ) 79 | } catch { 80 | print(error) 81 | } 82 | } 83 | } 84 | 85 | extension CategoryList.Container: View { 86 | var body: some View { 87 | let _ = Self.debugPrint() 88 | CategoryList.Presenter( 89 | categories: self.categories, 90 | status: self.status, 91 | selectedCategoryId: self.$selectedCategoryId, 92 | onAppear: self.onAppear, 93 | onReloadSampleData: self.onReloadSampleData 94 | ) 95 | } 96 | } 97 | 98 | extension CategoryList { 99 | @MainActor fileprivate struct Presenter { 100 | @State private var isAlertPresented = false 101 | 102 | private let categories: Array 103 | private let status: Status? 104 | @Binding private var selectedCategoryId: AnimalsData.Category.ID? 105 | private let onAppear: () -> Void 106 | private let onReloadSampleData: () -> Void 107 | 108 | init( 109 | categories: Array, 110 | status: Status?, 111 | selectedCategoryId: Binding, 112 | onAppear: @escaping () -> Void, 113 | onReloadSampleData: @escaping () -> Void 114 | ) { 115 | self.categories = categories 116 | self.status = status 117 | self._selectedCategoryId = selectedCategoryId 118 | self.onAppear = onAppear 119 | self.onReloadSampleData = onReloadSampleData 120 | } 121 | } 122 | } 123 | 124 | extension CategoryList.Presenter { 125 | var list: some View { 126 | List(selection: self.$selectedCategoryId) { 127 | Section("Categories") { 128 | ForEach(self.categories) { category in 129 | NavigationLink(category.name, value: category.id) 130 | } 131 | } 132 | } 133 | } 134 | } 135 | 136 | extension CategoryList.Presenter: View { 137 | var body: some View { 138 | let _ = Self.debugPrint() 139 | self.list 140 | .alert("Reload Sample Data?", isPresented: self.$isAlertPresented) { 141 | Button("Yes, reload sample data", role: .destructive) { 142 | self.onReloadSampleData() 143 | } 144 | } message: { 145 | Text("Reloading the sample data deletes all changes to the current data.") 146 | } 147 | .onAppear { 148 | self.onAppear() 149 | } 150 | .toolbar { 151 | Button { self.isAlertPresented = true } label: { 152 | Label("Reload sample data", systemImage: "arrow.clockwise") 153 | } 154 | .disabled(self.status == .waiting) 155 | } 156 | } 157 | } 158 | 159 | #Preview { 160 | @Previewable @State var selectedCategoryId: AnimalsData.Category.ID? 161 | NavigationStack { 162 | PreviewStore { 163 | CategoryList(selectedCategoryId: $selectedCategoryId) 164 | } 165 | } 166 | } 167 | 168 | #Preview { 169 | @Previewable @State var selectedCategoryId: AnimalsData.Category.ID? 170 | NavigationStack { 171 | CategoryList.Presenter( 172 | categories: [ 173 | Category.amphibian, 174 | Category.bird, 175 | Category.fish, 176 | Category.invertebrate, 177 | Category.mammal, 178 | Category.reptile, 179 | ], 180 | status: nil, 181 | selectedCategoryId: $selectedCategoryId, 182 | onAppear: { 183 | print("onAppear") 184 | }, 185 | onReloadSampleData: { 186 | print("onReloadSampleData") 187 | } 188 | ) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /Samples/AnimalsUI/Sources/AnimalsUI/AnimalDetail.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import AnimalsData 18 | import SwiftUI 19 | 20 | @MainActor struct AnimalDetail { 21 | private let selectedAnimalId: Animal.ID? 22 | 23 | init(selectedAnimalId: Animal.ID?) { 24 | self.selectedAnimalId = selectedAnimalId 25 | } 26 | } 27 | 28 | extension AnimalDetail : View { 29 | var body: some View { 30 | let _ = Self.debugPrint() 31 | Container(selectedAnimalId: self.selectedAnimalId) 32 | } 33 | } 34 | 35 | extension AnimalDetail { 36 | @MainActor fileprivate struct Container { 37 | @SelectAnimal private var animal: Animal? 38 | @SelectCategory private var category: AnimalsData.Category? 39 | @SelectAnimalStatus private var status: Status? 40 | 41 | @Dispatch private var dispatch 42 | 43 | init(selectedAnimalId: Animal.ID?) { 44 | self._animal = SelectAnimal(animalId: selectedAnimalId) 45 | self._category = SelectCategory(animalId: selectedAnimalId) 46 | self._status = SelectAnimalStatus(animalId: selectedAnimalId) 47 | } 48 | } 49 | } 50 | 51 | extension AnimalDetail.Container { 52 | private func onTapDeleteSelectedAnimalButton(animal: Animal) { 53 | do { 54 | try self.dispatch( 55 | .ui( 56 | .animalDetail( 57 | .onTapDeleteSelectedAnimalButton( 58 | animalId: animal.id 59 | ) 60 | ) 61 | ) 62 | ) 63 | } catch { 64 | print(error) 65 | } 66 | } 67 | } 68 | 69 | extension AnimalDetail.Container: View { 70 | var body: some View { 71 | let _ = Self.debugPrint() 72 | AnimalDetail.Presenter( 73 | animal: self.animal, 74 | category: self.category, 75 | status: self.status, 76 | onTapDeleteSelectedAnimalButton: self.onTapDeleteSelectedAnimalButton 77 | ) 78 | } 79 | } 80 | 81 | extension AnimalDetail { 82 | @MainActor fileprivate struct Presenter { 83 | @State private var isAlertPresented = false 84 | @State private var isSheetPresented = false 85 | 86 | private let animal: Animal? 87 | private let category: AnimalsData.Category? 88 | private let status: Status? 89 | private let onTapDeleteSelectedAnimalButton: (Animal) -> Void 90 | 91 | init( 92 | animal: Animal?, 93 | category: AnimalsData.Category?, 94 | status: Status?, 95 | onTapDeleteSelectedAnimalButton: @escaping (Animal) -> Void 96 | ) { 97 | self.animal = animal 98 | self.category = category 99 | self.status = status 100 | self.onTapDeleteSelectedAnimalButton = onTapDeleteSelectedAnimalButton 101 | } 102 | } 103 | } 104 | 105 | extension AnimalDetail.Presenter: View { 106 | var body: some View { 107 | let _ = Self.debugPrint() 108 | if let animal = self.animal, 109 | let category = self.category { 110 | VStack { 111 | Text(animal.name) 112 | .font(.title) 113 | .padding() 114 | List { 115 | HStack { 116 | Text("Category") 117 | Spacer() 118 | Text(category.name) 119 | } 120 | HStack { 121 | Text("Diet") 122 | Spacer() 123 | Text(animal.diet.rawValue) 124 | } 125 | } 126 | } 127 | .alert("Delete \(animal.name)?", isPresented: self.$isAlertPresented) { 128 | Button("Yes, delete \(animal.name)", role: .destructive) { 129 | self.onTapDeleteSelectedAnimalButton(animal) 130 | } 131 | } 132 | .sheet(isPresented: self.$isSheetPresented) { 133 | NavigationStack { 134 | AnimalEditor( 135 | id: animal.id, 136 | isPresented: self.$isSheetPresented 137 | ) 138 | } 139 | } 140 | .toolbar { 141 | Button { self.isSheetPresented = true } label: { 142 | Label("Edit \(animal.name)", systemImage: "pencil") 143 | } 144 | .disabled(self.status == .waiting) 145 | Button { self.isAlertPresented = true } label: { 146 | Label("Delete \(animal.name)", systemImage: "trash") 147 | } 148 | .disabled(self.status == .waiting) 149 | } 150 | } else { 151 | ContentUnavailableView("Select an animal", systemImage: "pawprint") 152 | } 153 | } 154 | } 155 | 156 | #Preview { 157 | NavigationStack { 158 | PreviewStore { 159 | AnimalDetail(selectedAnimalId: Animal.kangaroo.animalId) 160 | } 161 | } 162 | } 163 | 164 | #Preview { 165 | NavigationStack { 166 | PreviewStore { 167 | AnimalDetail(selectedAnimalId: nil) 168 | } 169 | } 170 | } 171 | 172 | #Preview { 173 | NavigationStack { 174 | AnimalDetail.Presenter( 175 | animal: .kangaroo, 176 | category: .mammal, 177 | status: nil, 178 | onTapDeleteSelectedAnimalButton: { animal in 179 | print("onTapDeleteSelectedAnimalButton: \(animal)") 180 | } 181 | ) 182 | } 183 | } 184 | 185 | #Preview { 186 | NavigationStack { 187 | AnimalDetail.Presenter( 188 | animal: nil, 189 | category: nil, 190 | status: nil, 191 | onTapDeleteSelectedAnimalButton: { animal in 192 | print("onTapDeleteSelectedAnimalButton: \(animal)") 193 | } 194 | ) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /Samples/Services/Tests/ServicesTests/NetworkSessionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | import Services 19 | import Testing 20 | 21 | // https://github.com/swiftlang/swift/issues/74882 22 | 23 | final fileprivate class Error : Swift.Error { 24 | 25 | } 26 | 27 | final fileprivate class NetworkSessionURLSessionTestDouble: @unchecked Sendable, NetworkSessionURLSession { 28 | var parameterRequest: URLRequest? 29 | var returnData: Data? 30 | var returnResponse: URLResponse? 31 | var returnError = Error() 32 | } 33 | 34 | extension NetworkSessionURLSessionTestDouble { 35 | func data(for request: URLRequest) async throws -> (Data, URLResponse) { 36 | self.parameterRequest = request 37 | guard 38 | let data = self.returnData, 39 | let response = self.returnResponse 40 | else { 41 | throw self.returnError 42 | } 43 | return (data, response) 44 | } 45 | } 46 | 47 | final fileprivate class JSONTestDouble: @unchecked Sendable, Decodable { 48 | 49 | } 50 | 51 | final fileprivate class NetworkSessionJSONHandlerTestDouble: @unchecked Sendable, NetworkSessionJSONHandler { 52 | static nonisolated(unsafe) var parameterData: Data? 53 | static nonisolated(unsafe) var parameterResponse: URLResponse? 54 | static nonisolated(unsafe) var returnJSON: JSONTestDouble? 55 | static let returnError = Error() 56 | } 57 | 58 | extension NetworkSessionJSONHandlerTestDouble { 59 | static func handle( 60 | _ data: Data, 61 | with response: URLResponse, 62 | dataHandler: DataHandler.Type, 63 | from decoder: JSONDecoder 64 | ) throws -> T where DataHandler : NetworkJSONHandlerDataHandler, JSONDecoder : NetworkJSONHandlerDecoder, T : Decodable { 65 | self.parameterData = data 66 | self.parameterResponse = response 67 | guard 68 | let json = self.returnJSON 69 | else { 70 | throw self.returnError 71 | } 72 | return json as! T 73 | } 74 | } 75 | 76 | @Suite(.serialized) final actor NetworkSessionTestCase { 77 | private let urlSession = NetworkSessionURLSessionTestDouble() 78 | 79 | init() { 80 | NetworkSessionJSONHandlerTestDouble.parameterData = nil 81 | NetworkSessionJSONHandlerTestDouble.parameterResponse = nil 82 | NetworkSessionJSONHandlerTestDouble.returnJSON = nil 83 | } 84 | } 85 | 86 | extension NetworkSessionTestCase { 87 | @Test func throwsSessionError() async throws { 88 | do { 89 | let session = NetworkSession(urlSession: self.urlSession) 90 | let _ = try await session.json( 91 | for: URLRequestTestDouble(), 92 | jsonHandler: NetworkSessionJSONHandlerTestDouble.self, 93 | dataHandler: NetworkDataHandler.self, 94 | from: JSONDecoder() 95 | ) as JSONTestDouble 96 | #expect(false) 97 | } catch { 98 | #expect(self.urlSession.parameterRequest == URLRequestTestDouble()) 99 | 100 | #expect(NetworkSessionJSONHandlerTestDouble.parameterData == nil) 101 | #expect(NetworkSessionJSONHandlerTestDouble.parameterResponse == nil) 102 | 103 | let error = try #require(error as? NetworkSession.Error) 104 | let underlying = try #require(error.underlying as? Error) 105 | #expect(error.code == .sessionError) 106 | #expect(underlying === self.urlSession.returnError) 107 | } 108 | } 109 | } 110 | 111 | extension NetworkSessionTestCase { 112 | @Test func throwsJSONHandlerError() async throws { 113 | do { 114 | self.urlSession.returnData = DataTestDouble() 115 | self.urlSession.returnResponse = URLResponseTestDouble() 116 | 117 | let session = NetworkSession(urlSession: self.urlSession) 118 | let _ = try await session.json( 119 | for: URLRequestTestDouble(), 120 | jsonHandler: NetworkSessionJSONHandlerTestDouble.self, 121 | dataHandler: NetworkDataHandler.self, 122 | from: JSONDecoder() 123 | ) as JSONTestDouble 124 | #expect(false) 125 | } catch { 126 | #expect(self.urlSession.parameterRequest == URLRequestTestDouble()) 127 | 128 | #expect(NetworkSessionJSONHandlerTestDouble.parameterData == self.urlSession.returnData) 129 | #expect(NetworkSessionJSONHandlerTestDouble.parameterResponse === self.urlSession.returnResponse) 130 | 131 | let error = try #require(error as? NetworkSession.Error) 132 | let underlying = try #require(error.underlying as? Error) 133 | #expect(error.code == .jsonHandlerError) 134 | #expect(underlying === NetworkSessionJSONHandlerTestDouble.returnError) 135 | } } 136 | } 137 | 138 | extension NetworkSessionTestCase { 139 | @Test func noThrow() async throws { 140 | self.urlSession.returnData = DataTestDouble() 141 | self.urlSession.returnResponse = URLResponseTestDouble() 142 | 143 | NetworkSessionJSONHandlerTestDouble.returnJSON = JSONTestDouble() 144 | 145 | let session = NetworkSession(urlSession: self.urlSession) 146 | let json = try await session.json( 147 | for: URLRequestTestDouble(), 148 | jsonHandler: NetworkSessionJSONHandlerTestDouble.self, 149 | dataHandler: NetworkDataHandler.self, 150 | from: JSONDecoder() 151 | ) as JSONTestDouble 152 | 153 | #expect(self.urlSession.parameterRequest == URLRequestTestDouble()) 154 | 155 | #expect(NetworkSessionJSONHandlerTestDouble.parameterData == self.urlSession.returnData) 156 | #expect(NetworkSessionJSONHandlerTestDouble.parameterResponse === self.urlSession.returnResponse) 157 | 158 | #expect(json === NetworkSessionJSONHandlerTestDouble.returnJSON) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Tests/AsyncSequenceTestUtilsTests/AsyncSequenceTestDoubleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import AsyncSequenceTestUtils 18 | import Testing 19 | 20 | final fileprivate actor AsyncListenerTestDouble where Element : Sendable { 21 | var values = Array() 22 | } 23 | 24 | extension AsyncListenerTestDouble { 25 | func listen(to sequence: Sequence) async rethrows where Sequence : AsyncSequence, Sequence : Sendable, Sequence.Element == Element { 26 | var iterator = sequence.makeAsyncIterator() 27 | while let value = try await iterator.next() { 28 | self.values.append(value) 29 | } 30 | } 31 | } 32 | 33 | final fileprivate actor ListenerTestDouble where Element : Sendable { 34 | var listener: AsyncListenerTestDouble? 35 | var task: Task? 36 | 37 | deinit { 38 | self.task?.cancel() 39 | } 40 | } 41 | 42 | extension ListenerTestDouble { 43 | func listen(to sequence: Sequence) where Sequence : AsyncSequence, Sequence : Sendable, Sequence.Element == Element { 44 | self.listener = AsyncListenerTestDouble() 45 | self.task?.cancel() 46 | self.task = Task { [listener] in 47 | try await listener?.listen(to: sequence) 48 | } 49 | } 50 | } 51 | 52 | extension ListenerTestDouble { 53 | var values: Array { 54 | get async { 55 | guard let listener = self.listener else { fatalError() } 56 | return await listener.values 57 | } 58 | } 59 | } 60 | 61 | @Suite final actor AsyncSequenceTestDoubleTests { 62 | 63 | } 64 | 65 | extension AsyncSequenceTestDoubleTests { 66 | @Test func asyncListenerFinished() async throws { 67 | let listener = AsyncListenerTestDouble() 68 | do { 69 | let sequence = AsyncSequenceTestDouble() 70 | 71 | let task = Task { 72 | try await listener.listen(to: sequence) 73 | } 74 | 75 | await sequence.iterator.resume(returning: 1) 76 | #expect(await listener.values == [1]) 77 | 78 | await sequence.iterator.resume(returning: 2) 79 | #expect(await listener.values == [1, 2]) 80 | 81 | await sequence.iterator.resume(returning: nil) 82 | 83 | await sequence.iterator.resume(returning: 3) 84 | #expect(await listener.values == [1, 2]) 85 | 86 | try await task.value 87 | } 88 | 89 | #expect(await listener.values == [1, 2]) 90 | } 91 | } 92 | 93 | extension AsyncSequenceTestDoubleTests { 94 | @Test func asyncListenerCancelled() async throws { 95 | let listener = AsyncListenerTestDouble() 96 | do { 97 | let sequence = AsyncSequenceTestDouble() 98 | 99 | let task = Task { 100 | try await listener.listen(to: sequence) 101 | } 102 | 103 | await sequence.iterator.resume(returning: 1) 104 | #expect(await listener.values == [1]) 105 | 106 | await sequence.iterator.resume(returning: 2) 107 | #expect(await listener.values == [1, 2]) 108 | 109 | task.cancel() 110 | 111 | await sequence.iterator.resume(returning: 3) 112 | #expect(await listener.values == [1, 2]) 113 | 114 | try await task.value 115 | } 116 | 117 | #expect(await listener.values == [1, 2]) 118 | } 119 | } 120 | 121 | extension AsyncSequenceTestDoubleTests { 122 | @Test func listenerFinished() async throws { 123 | let listener = ListenerTestDouble() 124 | do { 125 | let sequence = AsyncSequenceTestDouble() 126 | 127 | await listener.listen(to: sequence) 128 | 129 | await sequence.iterator.resume(returning: 1) 130 | #expect(await listener.values == [1]) 131 | 132 | await sequence.iterator.resume(returning: 2) 133 | #expect(await listener.values == [1, 2]) 134 | } 135 | do { 136 | let sequence = AsyncSequenceTestDouble() 137 | 138 | await listener.listen(to: sequence) 139 | 140 | await sequence.iterator.resume(returning: 1) 141 | #expect(await listener.values == [1]) 142 | 143 | await sequence.iterator.resume(returning: 2) 144 | #expect(await listener.values == [1, 2]) 145 | 146 | await sequence.iterator.resume(returning: nil) 147 | 148 | await sequence.iterator.resume(returning: 3) 149 | #expect(await listener.values == [1, 2]) 150 | 151 | try await listener.task?.value 152 | } 153 | 154 | #expect(await listener.values == [1, 2]) 155 | } 156 | } 157 | 158 | extension AsyncSequenceTestDoubleTests { 159 | @Test func listenerCancelled() async throws { 160 | let listener = ListenerTestDouble() 161 | do { 162 | let sequence = AsyncSequenceTestDouble() 163 | 164 | await listener.listen(to: sequence) 165 | 166 | await sequence.iterator.resume(returning: 1) 167 | #expect(await listener.values == [1]) 168 | 169 | await sequence.iterator.resume(returning: 2) 170 | #expect(await listener.values == [1, 2]) 171 | } 172 | do { 173 | let sequence = AsyncSequenceTestDouble() 174 | 175 | await listener.listen(to: sequence) 176 | 177 | await sequence.iterator.resume(returning: 1) 178 | #expect(await listener.values == [1]) 179 | 180 | await sequence.iterator.resume(returning: 2) 181 | #expect(await listener.values == [1, 2]) 182 | 183 | await listener.task?.cancel() 184 | 185 | await sequence.iterator.resume(returning: 3) 186 | #expect(await listener.values == [1, 2]) 187 | 188 | try await listener.task?.value 189 | } 190 | 191 | #expect(await listener.values == [1, 2]) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Samples/Services/Tests/ServicesTests/NetworkJSONHandlerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Foundation 18 | import Services 19 | import Testing 20 | 21 | final fileprivate class Error : Swift.Error { 22 | 23 | } 24 | 25 | final fileprivate class DataHandlerTestDouble: NetworkJSONHandlerDataHandler { 26 | static nonisolated(unsafe) var parameterData: Data? 27 | static nonisolated(unsafe) var parameterResponse: URLResponse? 28 | static nonisolated(unsafe) var returnData: Data? 29 | static let returnError = Error() 30 | } 31 | 32 | extension DataHandlerTestDouble { 33 | static func handle( 34 | _ data: Data, 35 | with response: URLResponse 36 | ) throws -> Data { 37 | self.parameterData = data 38 | self.parameterResponse = response 39 | guard 40 | let data = self.returnData 41 | else { 42 | throw self.returnError 43 | } 44 | return data 45 | } 46 | } 47 | 48 | final fileprivate class JSONTestDouble: Decodable { 49 | 50 | } 51 | 52 | final fileprivate class JSONDecoderTestDouble: NetworkJSONHandlerDecoder { 53 | var parameterType: Any.Type? 54 | var parameterData: Data? 55 | var returnJSON: JSONTestDouble? 56 | let returnError = Error() 57 | } 58 | 59 | extension JSONDecoderTestDouble { 60 | func decode( 61 | _ type: T.Type, 62 | from data: Data 63 | ) throws -> T where T : Decodable { 64 | self.parameterType = type 65 | self.parameterData = data 66 | guard 67 | let json = self.returnJSON 68 | else { 69 | throw self.returnError 70 | } 71 | return json as! T 72 | } 73 | } 74 | 75 | @Suite(.serialized) final actor NetworkJSONHandlerTestCase { 76 | init() { 77 | DataHandlerTestDouble.parameterData = nil 78 | DataHandlerTestDouble.parameterResponse = nil 79 | DataHandlerTestDouble.returnData = nil 80 | } 81 | } 82 | 83 | extension NetworkJSONHandlerTestCase { 84 | @Test func throwsMimeTypeError() throws { 85 | let response = HTTPURLResponseTestDouble(headerFields: ["content-type": "image/png"]) 86 | let decoder = JSONDecoderTestDouble() 87 | 88 | #expect { 89 | try NetworkJSONHandler.handle( 90 | DataTestDouble(), 91 | with: response, 92 | dataHandler: DataHandlerTestDouble.self, 93 | from: decoder 94 | ) as JSONTestDouble 95 | } throws: { error in 96 | #expect(DataHandlerTestDouble.parameterData == nil) 97 | #expect(DataHandlerTestDouble.parameterResponse == nil) 98 | 99 | #expect(decoder.parameterType == nil) 100 | #expect(decoder.parameterData == nil) 101 | 102 | let error = try #require(error as? NetworkJSONHandler.Error) 103 | return (error.code == .mimeTypeError) && (error.underlying == nil) 104 | } 105 | } 106 | } 107 | 108 | extension NetworkJSONHandlerTestCase { 109 | @Test func throwsDataHandlerError() throws { 110 | let response = HTTPURLResponseTestDouble(headerFields: ["content-type": "application/json"]) 111 | let decoder = JSONDecoderTestDouble() 112 | 113 | #expect { 114 | try NetworkJSONHandler.handle( 115 | DataTestDouble(), 116 | with: response, 117 | dataHandler: DataHandlerTestDouble.self, 118 | from: decoder 119 | ) as JSONTestDouble 120 | } throws: { error in 121 | #expect(DataHandlerTestDouble.parameterData == DataTestDouble()) 122 | #expect(DataHandlerTestDouble.parameterResponse === response) 123 | 124 | #expect(decoder.parameterType == nil) 125 | #expect(decoder.parameterData == nil) 126 | 127 | let error = try #require(error as? NetworkJSONHandler.Error) 128 | let underlying = try #require(error.underlying as? Error) 129 | return (error.code == .dataHandlerError) && (underlying === DataHandlerTestDouble.returnError) 130 | } 131 | } 132 | } 133 | 134 | extension NetworkJSONHandlerTestCase { 135 | @Test func throwsJSONDecoderError() { 136 | DataHandlerTestDouble.returnData = DataTestDouble() 137 | 138 | let response = HTTPURLResponseTestDouble(headerFields: ["content-type": "application/json"]) 139 | let decoder = JSONDecoderTestDouble() 140 | 141 | #expect { 142 | try NetworkJSONHandler.handle( 143 | DataTestDouble(), 144 | with: response, 145 | dataHandler: DataHandlerTestDouble.self, 146 | from: decoder 147 | ) as JSONTestDouble 148 | } throws: { error in 149 | #expect(DataHandlerTestDouble.parameterData == DataTestDouble()) 150 | #expect(DataHandlerTestDouble.parameterResponse === response) 151 | 152 | #expect(decoder.parameterType == JSONTestDouble.self) 153 | #expect(decoder.parameterData == DataTestDouble()) 154 | 155 | let error = try #require(error as? NetworkJSONHandler.Error) 156 | let underlying = try #require(error.underlying as? Error) 157 | return (error.code == .jsonDecoderError) && (underlying === decoder.returnError) 158 | } 159 | } 160 | } 161 | 162 | extension NetworkJSONHandlerTestCase { 163 | @Test func noThrow() throws { 164 | DataHandlerTestDouble.returnData = DataTestDouble() 165 | 166 | let response = HTTPURLResponseTestDouble(headerFields: ["content-type": "application/json"]) 167 | let decoder = JSONDecoderTestDouble() 168 | decoder.returnJSON = JSONTestDouble() 169 | 170 | let json = try NetworkJSONHandler.handle( 171 | DataTestDouble(), 172 | with: response, 173 | dataHandler: DataHandlerTestDouble.self, 174 | from: decoder 175 | ) as JSONTestDouble 176 | 177 | #expect(DataHandlerTestDouble.parameterData == DataTestDouble()) 178 | #expect(DataHandlerTestDouble.parameterResponse === response) 179 | 180 | #expect(decoder.parameterType == JSONTestDouble.self) 181 | #expect(decoder.parameterData == DataTestDouble()) 182 | 183 | #expect(json === decoder.returnJSON) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /Samples/QuakesData/Tests/QuakesDataTests/RemoteStoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import Collections 18 | import Foundation 19 | import ImmutableData 20 | import QuakesData 21 | import Testing 22 | 23 | extension Quake { 24 | fileprivate var dictionary : Dictionary { 25 | [ 26 | "properties" : [ 27 | "mag" : self.magnitude, 28 | "place" : self.name, 29 | "time" : self.time.timeIntervalSince1970 * 1000.0, 30 | "updated" : self.time.timeIntervalSince1970 * 1000.0, 31 | ], 32 | "geometry" : [ 33 | "coordinates" : [ 34 | self.longitude, 35 | self.latitude, 36 | ], 37 | ], 38 | "id" : self.quakeId, 39 | ] 40 | } 41 | } 42 | 43 | final fileprivate class Error : Swift.Error { 44 | 45 | } 46 | 47 | final fileprivate class NetworkSessionTestDouble : @unchecked Sendable { 48 | var request: URLRequest? 49 | var decoder: JSONDecoder? 50 | let data: Data? 51 | let error = Error() 52 | 53 | init(data: Data? = nil) { 54 | self.data = data 55 | } 56 | } 57 | 58 | extension NetworkSessionTestDouble : RemoteStoreNetworkSession { 59 | func json( 60 | for request: URLRequest, 61 | from decoder: JSONDecoder 62 | ) async throws -> T where T : Decodable { 63 | self.request = request 64 | self.decoder = decoder 65 | guard 66 | let data = self.data 67 | else { 68 | throw self.error 69 | } 70 | let response = try decoder.decode(RemoteResponse.self, from: data) 71 | return response as! T 72 | } 73 | } 74 | 75 | @Suite final actor RemoteStoreTests { 76 | private static let state = QuakesState( 77 | quakes: QuakesState.Quakes( 78 | data: TreeDictionary( 79 | Quake( 80 | quakeId: "1", 81 | magnitude: 0.5, 82 | time: Date(timeIntervalSince1970: 1.0), 83 | updated: Date(timeIntervalSince1970: 1.0), 84 | name: "West of California", 85 | longitude: -125, 86 | latitude: 35 87 | ), 88 | Quake( 89 | quakeId: "2", 90 | magnitude: 1.5, 91 | time: Date(timeIntervalSince1970: 1.0), 92 | updated: Date(timeIntervalSince1970: 1.0), 93 | name: "West of California", 94 | longitude: -125, 95 | latitude: 35 96 | ), 97 | Quake( 98 | quakeId: "3", 99 | magnitude: 2.5, 100 | time: Date(timeIntervalSince1970: 1.0), 101 | updated: Date(timeIntervalSince1970: 1.0), 102 | name: "West of California", 103 | longitude: -125, 104 | latitude: 35 105 | ), 106 | Quake( 107 | quakeId: "4", 108 | magnitude: 3.5, 109 | time: Date(timeIntervalSince1970: 1.0), 110 | updated: Date(timeIntervalSince1970: 1.0), 111 | name: "West of California", 112 | longitude: -125, 113 | latitude: 35 114 | ), 115 | Quake( 116 | quakeId: "5", 117 | magnitude: 4.5, 118 | time: Date(timeIntervalSince1970: 1.0), 119 | updated: Date(timeIntervalSince1970: 1.0), 120 | name: "West of California", 121 | longitude: -125, 122 | latitude: 35 123 | ), 124 | Quake( 125 | quakeId: "6", 126 | magnitude: 5.5, 127 | time: Date(timeIntervalSince1970: 1.0), 128 | updated: Date(timeIntervalSince1970: 1.0), 129 | name: "West of California", 130 | longitude: -125, 131 | latitude: 35 132 | ), 133 | Quake( 134 | quakeId: "7", 135 | magnitude: 6.5, 136 | time: Date(timeIntervalSince1970: 1.0), 137 | updated: Date(timeIntervalSince1970: 1.0), 138 | name: "West of California", 139 | longitude: -125, 140 | latitude: 35 141 | ), 142 | Quake( 143 | quakeId: "8", 144 | magnitude: 7.5, 145 | time: Date(timeIntervalSince1970: 1.0), 146 | updated: Date(timeIntervalSince1970: 1.0), 147 | name: "West of California", 148 | longitude: -125, 149 | latitude: 35 150 | ) 151 | ) 152 | ) 153 | ) 154 | } 155 | 156 | extension RemoteStoreTests { 157 | private static var data : Data { 158 | let features = Self.state.quakes.data.values.map { $0.dictionary } 159 | let dictionary : Dictionary = [ 160 | "features" : features 161 | ] 162 | let data = try! JSONSerialization.data(withJSONObject: dictionary) 163 | return data 164 | } 165 | } 166 | 167 | extension RemoteStoreTests { 168 | @Test( 169 | arguments: [ 170 | ( 171 | QuakesAction.UI.QuakeList.RefreshQuakesRange.allHour, 172 | "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson" 173 | ), 174 | ( 175 | QuakesAction.UI.QuakeList.RefreshQuakesRange.allDay, 176 | "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson" 177 | ), 178 | ( 179 | QuakesAction.UI.QuakeList.RefreshQuakesRange.allWeek, 180 | "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_week.geojson" 181 | ), 182 | ( 183 | QuakesAction.UI.QuakeList.RefreshQuakesRange.allMonth, 184 | "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson" 185 | ), 186 | ] 187 | ) func fetchRemoteQuakesQueryNoThrow( 188 | range: QuakesAction.UI.QuakeList.RefreshQuakesRange, 189 | string: String 190 | ) async throws { 191 | let session = NetworkSessionTestDouble(data: Self.data) 192 | let store = RemoteStore(session: session) 193 | let quakes = try await store.fetchRemoteQuakesQuery(range: range) 194 | 195 | let networkRequest = try #require(session.request) 196 | #expect(networkRequest.url == URL(string: string)) 197 | #expect(networkRequest.httpMethod == "GET") 198 | 199 | #expect(TreeDictionary(quakes) == Self.state.quakes.data) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /Samples/AnimalsUI/Sources/AnimalsUI/AnimalList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import AnimalsData 18 | import SwiftUI 19 | 20 | @MainActor struct AnimalList { 21 | private let selectedCategoryId: AnimalsData.Category.ID? 22 | @Binding private var selectedAnimalId: Animal.ID? 23 | 24 | init( 25 | selectedCategoryId: AnimalsData.Category.ID?, 26 | selectedAnimalId: Binding 27 | ) { 28 | self.selectedCategoryId = selectedCategoryId 29 | self._selectedAnimalId = selectedAnimalId 30 | } 31 | } 32 | 33 | extension AnimalList : View { 34 | var body: some View { 35 | Container( 36 | selectedCategoryId: self.selectedCategoryId, 37 | selectedAnimalId: self.$selectedAnimalId 38 | ) 39 | } 40 | } 41 | 42 | extension AnimalList { 43 | @MainActor fileprivate struct Container { 44 | @SelectAnimalsValues private var animals: Array 45 | @SelectCategory private var category: AnimalsData.Category? 46 | @SelectAnimalsStatus private var status: Status? 47 | 48 | @Binding private var selectedAnimalId: Animal.ID? 49 | 50 | @Dispatch private var dispatch 51 | 52 | init( 53 | selectedCategoryId: AnimalsData.Category.ID?, 54 | selectedAnimalId: Binding 55 | ) { 56 | self._animals = SelectAnimalsValues(categoryId: selectedCategoryId) 57 | self._category = SelectCategory(categoryId: selectedCategoryId) 58 | self._status = SelectAnimalsStatus() 59 | 60 | self._selectedAnimalId = selectedAnimalId 61 | } 62 | } 63 | } 64 | 65 | extension AnimalList.Container { 66 | private func onAppear() { 67 | do { 68 | try self.dispatch( 69 | .ui( 70 | .animalList( 71 | .onAppear 72 | ) 73 | ) 74 | ) 75 | } catch { 76 | print(error) 77 | } 78 | } 79 | } 80 | 81 | extension AnimalList.Container { 82 | private func onTapDeleteSelectedAnimalButton(animal: Animal) { 83 | do { 84 | try self.dispatch( 85 | .ui( 86 | .animalList( 87 | .onTapDeleteSelectedAnimalButton( 88 | animalId: animal.id 89 | ) 90 | ) 91 | ) 92 | ) 93 | } catch { 94 | print(error) 95 | } 96 | } 97 | } 98 | 99 | extension AnimalList.Container: View { 100 | var body: some View { 101 | let _ = Self.debugPrint() 102 | AnimalList.Presenter( 103 | animals: self.animals, 104 | category: self.category, 105 | status: self.status, 106 | selectedAnimalId: self.$selectedAnimalId, 107 | onAppear: self.onAppear, 108 | onTapDeleteSelectedAnimalButton: self.onTapDeleteSelectedAnimalButton 109 | ) 110 | } 111 | } 112 | 113 | extension AnimalList { 114 | @MainActor fileprivate struct Presenter { 115 | @State private var isSheetPresented = false 116 | 117 | private let animals: Array 118 | private let category: AnimalsData.Category? 119 | private let status: Status? 120 | @Binding private var selectedAnimalId: Animal.ID? 121 | private let onAppear: () -> Void 122 | private let onTapDeleteSelectedAnimalButton: (Animal) -> Void 123 | 124 | init( 125 | animals: Array, 126 | category: AnimalsData.Category?, 127 | status: Status?, 128 | selectedAnimalId: Binding, 129 | onAppear: @escaping () -> Void, 130 | onTapDeleteSelectedAnimalButton: @escaping (Animal) -> Void 131 | ) { 132 | self.animals = animals 133 | self.category = category 134 | self.status = status 135 | self._selectedAnimalId = selectedAnimalId 136 | self.onAppear = onAppear 137 | self.onTapDeleteSelectedAnimalButton = onTapDeleteSelectedAnimalButton 138 | } 139 | } 140 | } 141 | 142 | extension AnimalList.Presenter { 143 | var list: some View { 144 | List(selection: self.$selectedAnimalId) { 145 | ForEach(self.animals) { animal in 146 | NavigationLink(animal.name, value: animal.id) 147 | .deleteDisabled(false) 148 | } 149 | .onDelete { indexSet in 150 | for index in indexSet { 151 | let animal = self.animals[index] 152 | self.onTapDeleteSelectedAnimalButton(animal) 153 | } 154 | } 155 | } 156 | } 157 | } 158 | 159 | extension AnimalList.Presenter { 160 | var addButton: some View { 161 | Button { self.isSheetPresented = true } label: { 162 | Label("Add an animal", systemImage: "plus") 163 | } 164 | .disabled(self.status == .waiting) 165 | } 166 | } 167 | 168 | extension AnimalList.Presenter: View { 169 | var body: some View { 170 | let _ = Self.debugPrint() 171 | if let category = self.category { 172 | self.list 173 | .navigationTitle(category.name) 174 | .onAppear { 175 | self.onAppear() 176 | } 177 | .overlay { 178 | if self.animals.isEmpty { 179 | ContentUnavailableView { 180 | Label("No animals in this category", systemImage: "pawprint") 181 | } description: { 182 | self.addButton 183 | } 184 | } 185 | } 186 | .sheet(isPresented: self.$isSheetPresented) { 187 | NavigationStack { 188 | AnimalEditor( 189 | id: nil, 190 | isPresented: self.$isSheetPresented 191 | ) 192 | } 193 | } 194 | .toolbar { 195 | ToolbarItem(placement: .primaryAction) { 196 | self.addButton 197 | } 198 | } 199 | } else { 200 | ContentUnavailableView("Select a category", systemImage: "sidebar.left") 201 | } 202 | } 203 | } 204 | 205 | #Preview { 206 | @Previewable @State var selectedAnimal: Animal.ID? 207 | NavigationStack { 208 | PreviewStore { 209 | AnimalList( 210 | selectedCategoryId: Category.mammal.id, 211 | selectedAnimalId: $selectedAnimal 212 | ) 213 | } 214 | } 215 | } 216 | 217 | #Preview { 218 | @Previewable @State var selectedAnimalId: Animal.ID? 219 | NavigationStack { 220 | AnimalList.Presenter( 221 | animals: [ 222 | Animal.cat, 223 | Animal.dog, 224 | Animal.kangaroo, 225 | Animal.gibbon, 226 | ], 227 | category: .mammal, 228 | status: nil, 229 | selectedAnimalId: $selectedAnimalId, 230 | onAppear: { 231 | print("onAppear") 232 | }, 233 | onTapDeleteSelectedAnimalButton: { animal in 234 | print("onTapDeleteSelectedAnimalButton: \(animal)") 235 | } 236 | ) 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /Sources/ImmutableUI/AsyncListener.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import ImmutableData 18 | import Foundation 19 | import Observation 20 | 21 | // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0393-parameter-packs.md 22 | // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0395-observability.md 23 | // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0398-variadic-types.md 24 | // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0408-pack-iteration.md 25 | 26 | // https://github.com/swiftlang/swift/issues/73690 27 | 28 | extension UserDefaults { 29 | fileprivate var isDebug: Bool { 30 | self.bool(forKey: "com.northbronson.ImmutableUI.Debug") 31 | } 32 | } 33 | 34 | extension ImmutableData.Selector { 35 | @MainActor fileprivate func selectMany(_ select: repeat @escaping @Sendable (State) -> each T) -> (repeat each T) where repeat each T : Sendable { 36 | (repeat self.select(each select)) 37 | } 38 | } 39 | 40 | @MainActor @Observable final fileprivate class Storage { 41 | var output: Output? 42 | } 43 | 44 | @MainActor final class AsyncListener where State : Sendable, Action : Sendable, repeat each Dependency : Sendable, Output : Sendable { 45 | private let label: String? 46 | private let filter: (@Sendable (State, Action) -> Bool)? 47 | private let dependencySelector: (repeat DependencySelector) 48 | private let outputSelector: OutputSelector 49 | 50 | private var oldDependency: (repeat each Dependency)? 51 | private var oldOutput: Output? 52 | private let storage = Storage() 53 | 54 | init( 55 | label: String? = nil, 56 | filter isIncluded: (@Sendable (State, Action) -> Bool)? = nil, 57 | dependencySelector: repeat DependencySelector, 58 | outputSelector: OutputSelector 59 | ) { 60 | self.label = label 61 | self.filter = isIncluded 62 | self.dependencySelector = (repeat each dependencySelector) 63 | self.outputSelector = outputSelector 64 | } 65 | } 66 | 67 | extension AsyncListener { 68 | var output: Output { 69 | guard let output = self.storage.output else { fatalError("missing output") } 70 | return output 71 | } 72 | } 73 | 74 | extension AsyncListener { 75 | func update(with store: some ImmutableData.Selector) { 76 | #if DEBUG 77 | if let label = self.label, 78 | UserDefaults.standard.isDebug { 79 | print("[ImmutableUI][AsyncListener]: \(address(of: self)) Update: \(label)") 80 | } 81 | #endif 82 | if self.hasDependency { 83 | if self.updateDependency(with: store) { 84 | self.updateOutput(with: store) 85 | } 86 | } else { 87 | self.updateOutput(with: store) 88 | } 89 | } 90 | } 91 | 92 | extension AsyncListener { 93 | func listen( 94 | to stream: S, 95 | with store: some ImmutableData.Selector 96 | ) async throws where S : AsyncSequence, S : Sendable, S.Element == (oldState: State, action: Action) { 97 | if let filter = self.filter { 98 | for try await _ in stream.filter(filter) { 99 | self.update(with: store) 100 | } 101 | } else { 102 | for try await _ in stream { 103 | self.update(with: store) 104 | } 105 | } 106 | } 107 | } 108 | 109 | extension AsyncListener { 110 | private var hasDependency: Bool { 111 | isEmpty(repeat each self.dependencySelector) == false 112 | } 113 | } 114 | 115 | extension AsyncListener { 116 | private func updateDependency(with store: some ImmutableData.Selector) -> Bool { 117 | #if DEBUG 118 | if let label = self.label, 119 | UserDefaults.standard.isDebug { 120 | print("[ImmutableUI][AsyncListener]: \(address(of: self)) Update Dependency: \(label)") 121 | } 122 | #endif 123 | let dependency = store.selectMany(repeat (each self.dependencySelector).select) 124 | if let oldDependency = self.oldDependency { 125 | self.oldDependency = dependency 126 | return didChange( 127 | repeat (each self.dependencySelector).didChange, 128 | lhs: repeat each oldDependency, 129 | rhs: repeat each dependency 130 | ) 131 | } else { 132 | self.oldDependency = dependency 133 | return true 134 | } 135 | } 136 | } 137 | 138 | extension AsyncListener { 139 | private func updateOutput(with store: some ImmutableData.Selector) { 140 | #if DEBUG 141 | if let label = self.label, 142 | UserDefaults.standard.isDebug { 143 | print("[ImmutableUI][AsyncListener]: \(address(of: self)) Update Output: \(label)") 144 | } 145 | #endif 146 | let output = store.select(self.outputSelector.select) 147 | if let oldOutput = self.oldOutput { 148 | self.oldOutput = output 149 | if self.outputSelector.didChange(oldOutput, output) { 150 | self.storage.output = output 151 | } 152 | } else { 153 | self.oldOutput = output 154 | self.storage.output = output 155 | } 156 | } 157 | } 158 | 159 | fileprivate struct NotEmpty: Error {} 160 | 161 | fileprivate func isEmpty(_ element: repeat each Element) -> Bool { 162 | // https://forums.swift.org/t/how-to-pass-nil-as-an-optional-parameter-pack/73119/6 163 | 164 | func _isEmpty(_ t: T) throws { 165 | throw NotEmpty() 166 | } 167 | do { 168 | repeat try _isEmpty(each element) 169 | } catch { 170 | return false 171 | } 172 | return true 173 | } 174 | 175 | fileprivate struct DidChange: Error {} 176 | 177 | fileprivate func didChange( 178 | _ didChange: repeat @escaping @Sendable (each Element, each Element) -> Bool, 179 | lhs: repeat each Element, 180 | rhs: repeat each Element 181 | ) -> Bool { 182 | func _didChange(_ didChange: (T, T) -> Bool, _ lhs: T, _ rhs: T) throws { 183 | if didChange(lhs, rhs) { 184 | throw DidChange() 185 | } 186 | } 187 | do { 188 | repeat try _didChange(each didChange, each lhs, each rhs) 189 | } catch { 190 | return true 191 | } 192 | return false 193 | } 194 | 195 | fileprivate func address(of x: AnyObject) -> String { 196 | // https://github.com/apple/swift/blob/swift-5.10.1-RELEASE/stdlib/public/core/Runtime.swift#L516-L528 197 | // https://github.com/apple/swift/blob/swift-5.10.1-RELEASE/test/Concurrency/voucher_propagation.swift#L78-L81 198 | 199 | var result = String( 200 | unsafeBitCast(x, to: UInt.self), 201 | radix: 16 202 | ) 203 | for _ in 0..<(2 * MemoryLayout.size - result.utf16.count) { 204 | result = "0" + result 205 | } 206 | return "0x" + result 207 | } 208 | -------------------------------------------------------------------------------- /Samples/QuakesData/Sources/QuakesData/Listener.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 Rick van Voorden and Bill Fisher 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | // 16 | 17 | import ImmutableData 18 | import Foundation 19 | 20 | extension UserDefaults { 21 | fileprivate var isDebug: Bool { 22 | self.bool(forKey: "com.northbronson.QuakesData.Debug") 23 | } 24 | } 25 | 26 | @MainActor final public class Listener where LocalStore : PersistentSessionLocalStore, RemoteStore : PersistentSessionRemoteStore { 27 | private let session: PersistentSession 28 | 29 | private weak var store: AnyObject? 30 | private var task: Task? 31 | 32 | public init( 33 | localStore: LocalStore, 34 | remoteStore: RemoteStore 35 | ) { 36 | self.session = PersistentSession( 37 | localStore: localStore, 38 | remoteStore: remoteStore 39 | ) 40 | } 41 | 42 | deinit { 43 | self.task?.cancel() 44 | } 45 | } 46 | 47 | extension Listener { 48 | public func listen(to store: some ImmutableData.Dispatcher & ImmutableData.Selector & ImmutableData.Streamer & AnyObject) { 49 | if self.store !== store { 50 | self.store = store 51 | 52 | let stream = store.makeStream() 53 | 54 | self.task?.cancel() 55 | self.task = Task { [weak self] in 56 | for try await (oldState, action) in stream { 57 | #if DEBUG 58 | if UserDefaults.standard.isDebug { 59 | print("[QuakesData][Listener] Old State: \(oldState)") 60 | print("[QuakesData][Listener] Action: \(action)") 61 | let newState = store.select({ state in state }) 62 | print("[QuakesData][Listener] New State: \(newState)") 63 | } 64 | #endif 65 | guard let self = self else { return } 66 | await self.onReceive(from: store, oldState: oldState, action: action) 67 | } 68 | } 69 | } 70 | } 71 | } 72 | 73 | extension Listener { 74 | private func onReceive( 75 | from store: some ImmutableData.Dispatcher & ImmutableData.Selector, 76 | oldState: QuakesState, 77 | action: QuakesAction 78 | ) async { 79 | switch action { 80 | case .ui(.quakeList(action: let action)): 81 | await self.onReceive(from: store, oldState: oldState, action: action) 82 | case .data(.persistentSession(action: let action)): 83 | await self.onReceive(from: store, oldState: oldState, action: action) 84 | } 85 | } 86 | } 87 | 88 | extension Listener { 89 | private func onReceive( 90 | from store: some ImmutableData.Dispatcher & ImmutableData.Selector, 91 | oldState: QuakesState, 92 | action: QuakesAction.UI.QuakeList 93 | ) async { 94 | let newState = store.select({ state in state }) 95 | switch action { 96 | case .onAppear: 97 | if oldState.quakes.status == nil, 98 | newState.quakes.status == .waiting { 99 | do { 100 | try await store.dispatch( 101 | thunk: self.session.fetchLocalQuakesQuery() 102 | ) 103 | } catch { 104 | print(error) 105 | } 106 | } 107 | case .onTapRefreshQuakesButton(range: let range): 108 | if oldState.quakes.status != .waiting, 109 | newState.quakes.status == .waiting { 110 | do { 111 | try await store.dispatch( 112 | thunk: self.session.fetchRemoteQuakesQuery(range: range) 113 | ) 114 | } catch { 115 | print(error) 116 | } 117 | } 118 | case .onTapDeleteSelectedQuakeButton(quakeId: let quakeId): 119 | do { 120 | try await store.dispatch( 121 | thunk: self.session.deleteLocalQuakeMutation(quakeId: quakeId) 122 | ) 123 | } catch { 124 | print(error) 125 | } 126 | case .onTapDeleteAllQuakesButton: 127 | do { 128 | try await store.dispatch( 129 | thunk: self.session.deleteLocalQuakesMutation() 130 | ) 131 | } catch { 132 | print(error) 133 | } 134 | } 135 | } 136 | } 137 | 138 | extension Listener { 139 | private func onReceive( 140 | from store: some ImmutableData.Dispatcher & ImmutableData.Selector, 141 | oldState: QuakesState, 142 | action: QuakesAction.Data.PersistentSession 143 | ) async { 144 | switch action { 145 | case .localStore(action: let action): 146 | await self.onReceive(from: store, oldState: oldState, action: action) 147 | case .remoteStore(action: let action): 148 | await self.onReceive(from: store, oldState: oldState, action: action) 149 | } 150 | } 151 | } 152 | 153 | extension Listener { 154 | private func onReceive( 155 | from store: some ImmutableData.Dispatcher & ImmutableData.Selector, 156 | oldState: QuakesState, 157 | action: QuakesAction.Data.PersistentSession.LocalStore 158 | ) async { 159 | switch action { 160 | default: 161 | break 162 | } 163 | } 164 | } 165 | 166 | extension Listener { 167 | private func onReceive( 168 | from store: some ImmutableData.Dispatcher & ImmutableData.Selector, 169 | oldState: QuakesState, 170 | action: QuakesAction.Data.PersistentSession.RemoteStore 171 | ) async { 172 | let newState = store.select({ state in state }) 173 | switch action { 174 | case .didFetchQuakes(result: let result): 175 | switch result { 176 | case .success(quakes: let quakes): 177 | var inserted = Array() 178 | var updated = Array() 179 | var deleted = Array() 180 | for quake in quakes { 181 | if oldState.quakes.data[quake.id] == nil, 182 | newState.quakes.data[quake.id] != nil { 183 | inserted.append(quake) 184 | } 185 | if let oldQuake = oldState.quakes.data[quake.id], 186 | let quake = newState.quakes.data[quake.id], 187 | oldQuake != quake { 188 | updated.append(quake) 189 | } 190 | if oldState.quakes.data[quake.id] != nil, 191 | newState.quakes.data[quake.id] == nil { 192 | deleted.append(quake) 193 | } 194 | } 195 | do { 196 | try await store.dispatch( 197 | thunk: self.session.didFetchRemoteQuakesMutation( 198 | inserted: inserted, 199 | updated: updated, 200 | deleted: deleted 201 | ) 202 | ) 203 | } catch { 204 | print(error) 205 | } 206 | default: 207 | break 208 | } 209 | } 210 | } 211 | } 212 | --------------------------------------------------------------------------------