├── .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