diff --git a/0195-tca-concurrency-pt1/README.md b/0195-tca-concurrency-pt1/README.md new file mode 100644 index 00000000..cd47ca41 --- /dev/null +++ b/0195-tca-concurrency-pt1/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [Async Composable Architecture: The Problem](https://www.pointfree.co/episodes/ep195-async-composable-architecture-the-problem) +> +> The Composable Architecture’s fundamental unit of effect is modeled on Combine publishers because it was the simplest and most modern asynchrony tool available at the time. Now Swift has native concurrency tools, and so we want to make use of those tools in the library. But first, let’s see what can go wrong if we try to naively use async/await in an existing application. diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.github/CODE_OF_CONDUCT.md b/0195-tca-concurrency-pt1/swift-composable-architecture/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..703a4725 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,84 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at support@pointfree.co. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, +available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.github/ISSUE_TEMPLATE/bug_report.md b/0195-tca-concurrency-pt1/swift-composable-architecture/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..2142a4d0 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +Give a clear and concise description of what the bug is. + +**To Reproduce** +Zip up a project that reproduces the behavior and attach it by dragging it here. + +```swift +// And/or enter code that reproduces the behavior here. + +``` + +**Expected behavior** +Give a clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Environment** + - swift-composable-architecture version [e.g. 0.20.0] + - Xcode [e.g. 12.4] + - Swift [e.g. 5.4] + - OS: [e.g. iOS 14] + +**Additional context** +Add any more context about the problem here. diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.github/ISSUE_TEMPLATE/question.md b/0195-tca-concurrency-pt1/swift-composable-architecture/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 00000000..9e050064 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,10 @@ +--- +name: Question +about: Have a question about the Composable Architecture? +title: '' +labels: '' +assignees: '' + +--- + +The Composable Architecture uses GitHub issues for bugs. For more general discussion and help, please use [GitHub Discussions](https://github.com/pointfreeco/swift-composable-architecture/discussions) or [the Swift forum](https://forums.swift.org/c/related-projects/swift-composable-architecture) first. diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.github/workflows/ci.yml b/0195-tca-concurrency-pt1/swift-composable-architecture/.github/workflows/ci.yml new file mode 100644 index 00000000..081ca78c --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: + - main + - concurrency-updates + - concurrency-updates-proto + pull_request: + branches: + - '*' + workflow_dispatch: + +jobs: + library: + runs-on: macos-12 + strategy: + matrix: + xcode: [13.2.1, 13.4.1] + steps: + - uses: actions/checkout@v2 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Run tests + run: make test-library + - name: Compile documentation + if: ${{ matrix.xcode == '13.4.1' }} + run: make test-docs + - name: Run benchmark + run: make benchmark + + examples: + runs-on: macos-12 + steps: + - uses: actions/checkout@v2 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_13.4.1.app + - name: Run tests + run: make test-examples diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.github/workflows/documentation.yml b/0195-tca-concurrency-pt1/swift-composable-architecture/.github/workflows/documentation.yml new file mode 100644 index 00000000..e4721c76 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.github/workflows/documentation.yml @@ -0,0 +1,104 @@ +# Build and deploy DocC to GitHub pages. Based off of @karwa's work here: +# https://github.com/karwa/swift-url/blob/main/.github/workflows/docs.yml +name: Documentation + +on: + release: + types: + - published + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + runs-on: macos-12 + steps: + - name: Checkout Package + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Checkout swift-docc + uses: actions/checkout@v2 + with: + repository: apple/swift-docc + ref: main + path: swift-docc + - name: Cache DocC + id: cache-docc + uses: actions/cache@v2 + with: + key: swift-url-docc-build + path: swift-docc/.build + - name: Build swift-docc + if: ${{ !steps.cache-docc.outputs.cache-hit }} + run: | + cd swift-docc; swift build --product docc -c release; cd .. + + - name: Checkout swift-docc-render + uses: actions/checkout@v2 + with: + repository: apple/swift-docc-render + ref: main + path: swift-docc-render + - name: Build swift-docc-render + run: | + cd swift-docc-render; npm install && npm run build; cd .. + + - name: Checkout gh-pages Branch + uses: actions/checkout@v2 + with: + ref: gh-pages + path: docs-out + + - name: Build documentation + run: > + rm -rf docs-out/.git; + rm -rf docs-out/main; + + for tag in $(echo "main"; git tag); + do + echo "⏳ Generating documentation for "$tag" release."; + + if [ -d "docs-out/$tag" ] + then + echo "✅ Documentation for "$tag" already exists."; + else + git checkout "$tag"; + mkdir -p Sources/ComposableArchitecture/Documentation.docc; + export DOCC_HTML_DIR="$(pwd)/swift-docc-render/dist"; + + rm -rf .build/symbol-graphs; + mkdir -p .build/symbol-graphs; + swift build \ + --target ComposableArchitecture \ + -Xswiftc \ + -emit-symbol-graph \ + -Xswiftc \ + -emit-symbol-graph-dir \ + -Xswiftc \ + .build/symbol-graphs \ + && swift-docc/.build/release/docc convert Sources/ComposableArchitecture/Documentation.docc \ + --fallback-display-name ComposableArchitecture \ + --fallback-bundle-identifier co.pointfree.ComposableArchitecture \ + --fallback-bundle-version 0.0.0 \ + --additional-symbol-graph-dir \ + .build/symbol-graphs \ + --transform-for-static-hosting \ + --hosting-base-path /swift-composable-architecture/"$tag" \ + --output-path docs-out/"$tag" \ + && echo "✅ Documentation generated for "$tag" release." \ + || echo "⚠️ Documentation skipped for "$tag"."; + fi; + done + + - name: Fix permissions + run: 'sudo chown -R $USER docs-out' + - name: Publish documentation to GitHub Pages + uses: JamesIves/github-pages-deploy-action@4.1.7 + with: + branch: gh-pages + folder: docs-out + single-commit: true diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.github/workflows/format.yml b/0195-tca-concurrency-pt1/swift-composable-architecture/.github/workflows/format.yml new file mode 100644 index 00000000..dc82144b --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.github/workflows/format.yml @@ -0,0 +1,27 @@ +name: Format + +on: + push: + branches: + - main + +jobs: + swift_format: + name: swift-format + runs-on: macOS-11 + steps: + - uses: actions/checkout@v2 + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_13.0.app + - name: Tap + run: brew tap pointfreeco/formulae + - name: Install + run: brew install Formulae/swift-format@5.5 + - name: Format + run: make format + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Run swift-format + branch: 'main' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.gitignore b/0195-tca-concurrency-pt1/swift-composable-architecture/.gitignore new file mode 100644 index 00000000..17f73d0a --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +Package.resolved diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.spi.yml b/0195-tca-concurrency-pt1/swift-composable-architecture/.spi.yml new file mode 100644 index 00000000..f704e73c --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.spi.yml @@ -0,0 +1,12 @@ +version: 1 +builder: + configs: + - platform: ios + scheme: ComposableArchitecture + - platform: macos-xcodebuild + scheme: ComposableArchitecture + - platform: tvos + scheme: ComposableArchitecture + - platform: watchos + scheme: ComposableArchitecture_watchOS + - documentation_targets: [ComposableArchitecture] diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture.xcscheme b/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture.xcscheme new file mode 100644 index 00000000..98d7b896 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture_watchOS.xcscheme b/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture_watchOS.xcscheme new file mode 100644 index 00000000..e73f1e0a --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/xcshareddata/xcschemes/ComposableArchitecture_watchOS.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme b/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme new file mode 100644 index 00000000..267ef6c8 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/xcshareddata/xcschemes/swift-composable-architecture-benchmarks.xcscheme b/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/xcshareddata/xcschemes/swift-composable-architecture-benchmarks.xcscheme new file mode 100644 index 00000000..39a4f55e --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/.swiftpm/xcode/xcshareddata/xcschemes/swift-composable-architecture-benchmarks.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/ComposableArchitecture.xcworkspace/contents.xcworkspacedata b/0195-tca-concurrency-pt1/swift-composable-architecture/ComposableArchitecture.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..5cc16120 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/ComposableArchitecture.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0195-tca-concurrency-pt1/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/0195-tca-concurrency-pt1/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..08de0be8 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj new file mode 100644 index 00000000..9d98ae65 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj @@ -0,0 +1,1273 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 4F5AC11F24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5AC11E24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift */; }; + CA0C0C4724B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0C0C4624B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift */; }; + CA0C51FB245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */; }; + CA25E5D224463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA25E5D124463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift */; }; + CA27C0B7245780CE00CB1E59 /* 02-Effects-SystemEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA27C0B6245780CE00CB1E59 /* 02-Effects-SystemEnvironment.swift */; }; + CA34170824A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA34170724A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift */; }; + CA3E421F26B8337500581ABC /* 01-GettingStarted-FocusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA3E421E26B8337500581ABC /* 01-GettingStarted-FocusState.swift */; }; + CA3E4C5B24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA3E4C5A24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift */; }; + CA410EE0247A15FE00E41798 /* 02-Effects-WebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA410EDF247A15FE00E41798 /* 02-Effects-WebSocket.swift */; }; + CA410EE2247C73B400E41798 /* 02-Effects-WebSocketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA410EE1247C73B400E41798 /* 02-Effects-WebSocketTests.swift */; }; + CA50BE6024A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift */; }; + CA5ECF92267A79F0002067FF /* FactClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5ECF91267A79F0002067FF /* FactClient.swift */; }; + CA6AC2642451135C00C71CB3 /* ReusableComponents-Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2602451135C00C71CB3 /* ReusableComponents-Download.swift */; }; + CA6AC2652451135C00C71CB3 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2612451135C00C71CB3 /* CircularProgressView.swift */; }; + CA6AC2662451135C00C71CB3 /* DownloadComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2622451135C00C71CB3 /* DownloadComponent.swift */; }; + CA6AC2672451135C00C71CB3 /* DownloadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA6AC2632451135C00C71CB3 /* DownloadClient.swift */; }; + CA7BC8EE245CCFE4001FB69F /* 01-GettingStarted-SharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA7BC8ED245CCFE4001FB69F /* 01-GettingStarted-SharedState.swift */; }; + CAA9ADC22446587C0003A984 /* 02-Effects-Basics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC12446587C0003A984 /* 02-Effects-Basics.swift */; }; + CAA9ADC424465AB00003A984 /* 02-Effects-BasicsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC324465AB00003A984 /* 02-Effects-BasicsTests.swift */; }; + CAA9ADC624465C810003A984 /* 02-Effects-Cancellation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC524465C810003A984 /* 02-Effects-Cancellation.swift */; }; + CAA9ADC824465D950003A984 /* 02-Effects-CancellationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC724465D950003A984 /* 02-Effects-CancellationTests.swift */; }; + CAA9ADCA2446605B0003A984 /* 02-Effects-LongLiving.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */; }; + CAA9ADCC2446615B0003A984 /* 02-Effects-LongLivingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */; }; + CABC4F3926AEE00C00D5FA2C /* 02-Effects-Refreshable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3826AEE00C00D5FA2C /* 02-Effects-Refreshable.swift */; }; + CABC4F3B26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3A26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift */; }; + CAE962FD24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift */; }; + CAF069D024ACC5AF00A1AAEF /* 00-Core.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF069CF24ACC5AF00A1AAEF /* 00-Core.swift */; }; + CAF88E7324B8E26D00539345 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E7224B8E26D00539345 /* AppDelegate.swift */; }; + CAF88E7524B8E26D00539345 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E7424B8E26D00539345 /* RootView.swift */; }; + CAF88E7724B8E26E00539345 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAF88E7624B8E26E00539345 /* Assets.xcassets */; }; + CAF88E8824B8E26E00539345 /* tvOSCaseStudiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E8724B8E26E00539345 /* tvOSCaseStudiesTests.swift */; }; + CAF88E9124B8E3AF00539345 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = CAF88E9024B8E3AF00539345 /* ComposableArchitecture */; }; + CAF88E9324B8E3D000539345 /* Core.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E9224B8E3D000539345 /* Core.swift */; }; + CAF88E9524B8E4D500539345 /* FocusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E9424B8E4D500539345 /* FocusView.swift */; }; + DC07231724465D1E003A8B65 /* 02-Effects-TimersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC07231624465D1E003A8B65 /* 02-Effects-TimersTests.swift */; }; + DC072322244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC072321244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift */; }; + DC13940E2469E25C00EE1157 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = DC13940D2469E25C00EE1157 /* ComposableArchitecture */; }; + DC1394102469E27300EE1157 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = DC13940F2469E27300EE1157 /* ComposableArchitecture */; }; + DC25DC5F2450F13200082E81 /* IfLetStoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC25DC5E2450F13200082E81 /* IfLetStoreController.swift */; }; + DC25DC612450F2B000082E81 /* LoadThenNavigate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC25DC602450F2B000082E81 /* LoadThenNavigate.swift */; }; + DC25DC642450F2DF00082E81 /* ActivityIndicatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC25DC632450F2DF00082E81 /* ActivityIndicatorViewController.swift */; }; + DC27215625BF84FC00D9C8DB /* 01-GettingStarted-BindingBasicsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC27215525BF84FC00D9C8DB /* 01-GettingStarted-BindingBasicsTests.swift */; }; + DC4C6EAC2450DD380066A05D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6EAB2450DD380066A05D /* SceneDelegate.swift */; }; + DC4C6EAE2450DD380066A05D /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6EAD2450DD380066A05D /* RootViewController.swift */; }; + DC4C6EB02450DD380066A05D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC4C6EAF2450DD380066A05D /* Assets.xcassets */; }; + DC4C6EB32450DD380066A05D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC4C6EB22450DD380066A05D /* Preview Assets.xcassets */; }; + DC4C6EB62450DD380066A05D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DC4C6EB42450DD380066A05D /* LaunchScreen.storyboard */; }; + DC4C6EC12450DD390066A05D /* UIKitCaseStudiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6EC02450DD390066A05D /* UIKitCaseStudiesTests.swift */; }; + DC4C6ED62450E1050066A05D /* CounterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6ED52450E1050066A05D /* CounterViewController.swift */; }; + DC4C6ED82450E4570066A05D /* UIViewRepresented.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6ED72450E4570066A05D /* UIViewRepresented.swift */; }; + DC4C6EDA2450E6050066A05D /* NavigateAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4C6ED92450E6050066A05D /* NavigateAndLoad.swift */; }; + DC5B505125C86EBC000D8DFD /* 01-GettingStarted-Bindings-Forms.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5B505025C86EBC000D8DFD /* 01-GettingStarted-Bindings-Forms.swift */; }; + DC630FDA2451016B00BAECBA /* ListsOfState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC630FD92451016B00BAECBA /* ListsOfState.swift */; }; + DC634B252448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC634B242448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift */; }; + DC85EBC3285A731E00431CF3 /* ResignFirstResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC85EBC2285A731E00431CF3 /* ResignFirstResponder.swift */; }; + DC88D8A6245341EC0077F427 /* 01-GettingStarted-Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC88D8A5245341EC0077F427 /* 01-GettingStarted-Animations.swift */; }; + DC89C41B24460F95006900B9 /* 00-RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C41A24460F95006900B9 /* 00-RootView.swift */; }; + DC89C41D24460F96006900B9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DC89C41C24460F96006900B9 /* Assets.xcassets */; }; + DC89C4442446111B006900B9 /* 01-GettingStarted-Counter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C4432446111B006900B9 /* 01-GettingStarted-Counter.swift */; }; + DC89C449244618D5006900B9 /* 03-Navigation-LoadThenNavigate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C448244618D5006900B9 /* 03-Navigation-LoadThenNavigate.swift */; }; + DC89C44D244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C44C244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift */; }; + DC89C45124462DE7006900B9 /* 03-Navigation-Lists-LoadThenNavigate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C45024462DE7006900B9 /* 03-Navigation-Lists-LoadThenNavigate.swift */; }; + DC89C45324465452006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C45224465451006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift */; }; + DC89C45524465C44006900B9 /* 02-Effects-Timers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C45424465C44006900B9 /* 02-Effects-Timers.swift */; }; + DC9EB4172450CBD2005F413B /* UIViewRepresented.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9EB4162450CBD2005F413B /* UIViewRepresented.swift */; }; + DCAC2A4F2452352E0094DEF5 /* 04-HigherOrderReducers-ElmLikeSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAC2A4E2452352E0094DEF5 /* 04-HigherOrderReducers-ElmLikeSubscriptions.swift */; }; + DCC68EAB244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EAA244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift */; }; + DCC68EDD2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EDC2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift */; }; + DCC68EDF2447BC810037F998 /* TemplateText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EDE2447BC810037F998 /* TemplateText.swift */; }; + DCC68EE12447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EE02447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift */; }; + DCC68EE32447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC68EE22447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift */; }; + DCD442C6286CA91F008B4EA7 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD442C5286CA91F008B4EA7 /* AboutView.swift */; }; + DCE63B71245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE63B70245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift */; }; + DCFE1960278DBF0600C14CCF /* CaseStudiesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFE195F278DBF0600C14CCF /* CaseStudiesApp.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + CAF88E8424B8E26E00539345 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DC89C40B24460F95006900B9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = CAF88E6F24B8E26D00539345; + remoteInfo = tvOSCaseStudies; + }; + DC4C6EBD2450DD390066A05D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DC89C40B24460F95006900B9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DC4C6EA62450DD380066A05D; + remoteInfo = UIKitCaseStudies; + }; + DC89C42A24460F96006900B9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DC89C40B24460F95006900B9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DC89C41224460F95006900B9; + remoteInfo = CaseStudies; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + DC4C6ECD2450E0B30066A05D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + DC4C6ED32450E0BA0066A05D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C43D2446106D006900B9 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C44124461077006900B9 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 4F5AC11E24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-SharedStateTests.swift"; sourceTree = ""; }; + CA0C0C4624B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-LifecycleTests.swift"; sourceTree = ""; }; + CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift"; sourceTree = ""; }; + CA25E5D124463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Bindings-Basics.swift"; sourceTree = ""; }; + CA27C0B6245780CE00CB1E59 /* 02-Effects-SystemEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-SystemEnvironment.swift"; sourceTree = ""; }; + CA34170724A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AnimationsTests.swift"; sourceTree = ""; }; + CA3E421E26B8337500581ABC /* 01-GettingStarted-FocusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-FocusState.swift"; sourceTree = ""; }; + CA3E4C5A24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-Lifecycle.swift"; sourceTree = ""; }; + CA410EDF247A15FE00E41798 /* 02-Effects-WebSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-WebSocket.swift"; sourceTree = ""; }; + CA410EE1247C73B400E41798 /* 02-Effects-WebSocketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-WebSocketTests.swift"; sourceTree = ""; }; + CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AlertsAndConfirmationDialogsTests.swift"; sourceTree = ""; }; + CA5ECF91267A79F0002067FF /* FactClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactClient.swift; sourceTree = ""; }; + CA6AC2602451135C00C71CB3 /* ReusableComponents-Download.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReusableComponents-Download.swift"; sourceTree = ""; }; + CA6AC2612451135C00C71CB3 /* CircularProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; + CA6AC2622451135C00C71CB3 /* DownloadComponent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadComponent.swift; sourceTree = ""; }; + CA6AC2632451135C00C71CB3 /* DownloadClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadClient.swift; sourceTree = ""; }; + CA7BC8ED245CCFE4001FB69F /* 01-GettingStarted-SharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-SharedState.swift"; sourceTree = ""; }; + CAA9ADC12446587C0003A984 /* 02-Effects-Basics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Basics.swift"; sourceTree = ""; }; + CAA9ADC324465AB00003A984 /* 02-Effects-BasicsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-BasicsTests.swift"; sourceTree = ""; }; + CAA9ADC524465C810003A984 /* 02-Effects-Cancellation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Cancellation.swift"; sourceTree = ""; }; + CAA9ADC724465D950003A984 /* 02-Effects-CancellationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-CancellationTests.swift"; sourceTree = ""; }; + CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-LongLiving.swift"; sourceTree = ""; }; + CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-LongLivingTests.swift"; sourceTree = ""; }; + CABC4F3826AEE00C00D5FA2C /* 02-Effects-Refreshable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Refreshable.swift"; sourceTree = ""; }; + CABC4F3A26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-RefreshableTests.swift"; sourceTree = ""; }; + CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AlertsAndConfirmationDialogs.swift"; sourceTree = ""; }; + CAF069CF24ACC5AF00A1AAEF /* 00-Core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "00-Core.swift"; sourceTree = ""; }; + CAF88E7024B8E26D00539345 /* tvOSCaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = tvOSCaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; + CAF88E7224B8E26D00539345 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + CAF88E7424B8E26D00539345 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + CAF88E7624B8E26E00539345 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + CAF88E7E24B8E26E00539345 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CAF88E8324B8E26E00539345 /* tvOSCaseStudiesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = tvOSCaseStudiesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + CAF88E8724B8E26E00539345 /* tvOSCaseStudiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSCaseStudiesTests.swift; sourceTree = ""; }; + CAF88E9224B8E3D000539345 /* Core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Core.swift; sourceTree = ""; }; + CAF88E9424B8E4D500539345 /* FocusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusView.swift; sourceTree = ""; }; + DC07231624465D1E003A8B65 /* 02-Effects-TimersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-TimersTests.swift"; sourceTree = ""; }; + DC072321244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Sheet-LoadThenPresent.swift"; sourceTree = ""; }; + DC25DC5E2450F13200082E81 /* IfLetStoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IfLetStoreController.swift; sourceTree = ""; }; + DC25DC602450F2B000082E81 /* LoadThenNavigate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadThenNavigate.swift; sourceTree = ""; }; + DC25DC632450F2DF00082E81 /* ActivityIndicatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorViewController.swift; sourceTree = ""; }; + DC27215525BF84FC00D9C8DB /* 01-GettingStarted-BindingBasicsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-BindingBasicsTests.swift"; sourceTree = ""; }; + DC4C6EA72450DD380066A05D /* UIKitCaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UIKitCaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DC4C6EAB2450DD380066A05D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + DC4C6EAD2450DD380066A05D /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = ""; }; + DC4C6EAF2450DD380066A05D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + DC4C6EB22450DD380066A05D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + DC4C6EB52450DD380066A05D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + DC4C6EB72450DD380066A05D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DC4C6EBC2450DD390066A05D /* UIKitCaseStudiesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UIKitCaseStudiesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + DC4C6EC02450DD390066A05D /* UIKitCaseStudiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitCaseStudiesTests.swift; sourceTree = ""; }; + DC4C6EC22450DD390066A05D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DC4C6ED52450E1050066A05D /* CounterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CounterViewController.swift; sourceTree = ""; }; + DC4C6ED72450E4570066A05D /* UIViewRepresented.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewRepresented.swift; sourceTree = ""; }; + DC4C6ED92450E6050066A05D /* NavigateAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigateAndLoad.swift; sourceTree = ""; }; + DC5B505025C86EBC000D8DFD /* 01-GettingStarted-Bindings-Forms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Bindings-Forms.swift"; sourceTree = ""; }; + DC630FD92451016B00BAECBA /* ListsOfState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListsOfState.swift; sourceTree = ""; }; + DC634B242448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-ReusableFavoritingTests.swift"; sourceTree = ""; }; + DC85EBC2285A731E00431CF3 /* ResignFirstResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResignFirstResponder.swift; sourceTree = ""; }; + DC88D8A5245341EC0077F427 /* 01-GettingStarted-Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Animations.swift"; sourceTree = ""; }; + DC89C41324460F95006900B9 /* SwiftUICaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUICaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DC89C41A24460F95006900B9 /* 00-RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "00-RootView.swift"; sourceTree = ""; }; + DC89C41C24460F96006900B9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + DC89C42424460F96006900B9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DC89C42924460F96006900B9 /* SwiftUICaseStudiesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftUICaseStudiesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + DC89C43824460FC7006900B9 /* swift-composable-architecture */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "swift-composable-architecture"; path = ../..; sourceTree = ""; }; + DC89C43924460FFF006900B9 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + DC89C4432446111B006900B9 /* 01-GettingStarted-Counter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Counter.swift"; sourceTree = ""; }; + DC89C448244618D5006900B9 /* 03-Navigation-LoadThenNavigate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-LoadThenNavigate.swift"; sourceTree = ""; }; + DC89C44C244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-NavigateAndLoad.swift"; sourceTree = ""; }; + DC89C45024462DE7006900B9 /* 03-Navigation-Lists-LoadThenNavigate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Lists-LoadThenNavigate.swift"; sourceTree = ""; }; + DC89C45224465451006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Lists-NavigateAndLoad.swift"; sourceTree = ""; }; + DC89C45424465C44006900B9 /* 02-Effects-Timers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Timers.swift"; sourceTree = ""; }; + DC9EB4162450CBD2005F413B /* UIViewRepresented.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewRepresented.swift; sourceTree = ""; }; + DCAC2A4E2452352E0094DEF5 /* 04-HigherOrderReducers-ElmLikeSubscriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-ElmLikeSubscriptions.swift"; sourceTree = ""; }; + DCC68EAA244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Sheet-PresentAndLoad.swift"; sourceTree = ""; }; + DCC68EDC2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-OptionalState.swift"; sourceTree = ""; }; + DCC68EDE2447BC810037F998 /* TemplateText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateText.swift; sourceTree = ""; }; + DCC68EE02447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Composition-TwoCounters.swift"; sourceTree = ""; }; + DCC68EE22447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-ReusableFavoriting.swift"; sourceTree = ""; }; + DCD442C5286CA91F008B4EA7 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + DCE63B70245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-Recursion.swift"; sourceTree = ""; }; + DCFE195F278DBF0600C14CCF /* CaseStudiesApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaseStudiesApp.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + CAF88E6D24B8E26D00539345 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CAF88E9124B8E3AF00539345 /* ComposableArchitecture in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CAF88E8024B8E26E00539345 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC4C6EA42450DD380066A05D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DC1394102469E27300EE1157 /* ComposableArchitecture in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC4C6EB92450DD390066A05D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C41024460F95006900B9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DC13940E2469E25C00EE1157 /* ComposableArchitecture in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C42624460F96006900B9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + CA6AC25F2451131C00C71CB3 /* 04-HigherOrderReducers-ResuableOfflineDownloads */ = { + isa = PBXGroup; + children = ( + CA6AC2632451135C00C71CB3 /* DownloadClient.swift */, + CA6AC2622451135C00C71CB3 /* DownloadComponent.swift */, + CA6AC2602451135C00C71CB3 /* ReusableComponents-Download.swift */, + ); + path = "04-HigherOrderReducers-ResuableOfflineDownloads"; + sourceTree = ""; + }; + CAF88E7124B8E26D00539345 /* tvOSCaseStudies */ = { + isa = PBXGroup; + children = ( + CAF88E7E24B8E26E00539345 /* Info.plist */, + CAF88E7224B8E26D00539345 /* AppDelegate.swift */, + CAF88E9424B8E4D500539345 /* FocusView.swift */, + CAF88E9224B8E3D000539345 /* Core.swift */, + CAF88E7424B8E26D00539345 /* RootView.swift */, + CAF88E7624B8E26E00539345 /* Assets.xcassets */, + ); + path = tvOSCaseStudies; + sourceTree = ""; + }; + CAF88E8624B8E26E00539345 /* tvOSCaseStudiesTests */ = { + isa = PBXGroup; + children = ( + CAF88E8724B8E26E00539345 /* tvOSCaseStudiesTests.swift */, + ); + path = tvOSCaseStudiesTests; + sourceTree = ""; + }; + DC25DC622450F2D100082E81 /* Internal */ = { + isa = PBXGroup; + children = ( + DC25DC632450F2DF00082E81 /* ActivityIndicatorViewController.swift */, + DC25DC5E2450F13200082E81 /* IfLetStoreController.swift */, + DC4C6ED72450E4570066A05D /* UIViewRepresented.swift */, + ); + path = Internal; + sourceTree = ""; + }; + DC4C6EA82450DD380066A05D /* UIKitCaseStudies */ = { + isa = PBXGroup; + children = ( + DC4C6EAB2450DD380066A05D /* SceneDelegate.swift */, + DC4C6EAD2450DD380066A05D /* RootViewController.swift */, + DC4C6ED52450E1050066A05D /* CounterViewController.swift */, + DC630FD92451016B00BAECBA /* ListsOfState.swift */, + DC4C6ED92450E6050066A05D /* NavigateAndLoad.swift */, + DC25DC602450F2B000082E81 /* LoadThenNavigate.swift */, + DC25DC622450F2D100082E81 /* Internal */, + DC4C6EAF2450DD380066A05D /* Assets.xcassets */, + DC4C6EB42450DD380066A05D /* LaunchScreen.storyboard */, + DC4C6EB72450DD380066A05D /* Info.plist */, + DC4C6EB12450DD380066A05D /* Preview Content */, + ); + path = UIKitCaseStudies; + sourceTree = ""; + }; + DC4C6EB12450DD380066A05D /* Preview Content */ = { + isa = PBXGroup; + children = ( + DC4C6EB22450DD380066A05D /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + DC4C6EBF2450DD390066A05D /* UIKitCaseStudiesTests */ = { + isa = PBXGroup; + children = ( + DC4C6EC02450DD390066A05D /* UIKitCaseStudiesTests.swift */, + DC4C6EC22450DD390066A05D /* Info.plist */, + ); + path = UIKitCaseStudiesTests; + sourceTree = ""; + }; + DC89C40A24460F95006900B9 = { + isa = PBXGroup; + children = ( + DC89C43824460FC7006900B9 /* swift-composable-architecture */, + DC89C43924460FFF006900B9 /* README.md */, + DC89C41424460F95006900B9 /* Products */, + DC89C41524460F95006900B9 /* SwiftUICaseStudies */, + DC89C42C24460F96006900B9 /* SwiftUICaseStudiesTests */, + CAF88E7124B8E26D00539345 /* tvOSCaseStudies */, + CAF88E8624B8E26E00539345 /* tvOSCaseStudiesTests */, + DC4C6EA82450DD380066A05D /* UIKitCaseStudies */, + DC4C6EBF2450DD390066A05D /* UIKitCaseStudiesTests */, + ); + sourceTree = ""; + }; + DC89C41424460F95006900B9 /* Products */ = { + isa = PBXGroup; + children = ( + DC89C41324460F95006900B9 /* SwiftUICaseStudies.app */, + DC89C42924460F96006900B9 /* SwiftUICaseStudiesTests.xctest */, + DC4C6EA72450DD380066A05D /* UIKitCaseStudies.app */, + DC4C6EBC2450DD390066A05D /* UIKitCaseStudiesTests.xctest */, + CAF88E7024B8E26D00539345 /* tvOSCaseStudies.app */, + CAF88E8324B8E26E00539345 /* tvOSCaseStudiesTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + DC89C41524460F95006900B9 /* SwiftUICaseStudies */ = { + isa = PBXGroup; + children = ( + DC89C42424460F96006900B9 /* Info.plist */, + CAF069CF24ACC5AF00A1AAEF /* 00-Core.swift */, + DC89C41A24460F95006900B9 /* 00-RootView.swift */, + CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift */, + DC88D8A5245341EC0077F427 /* 01-GettingStarted-Animations.swift */, + CA25E5D124463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift */, + DC5B505025C86EBC000D8DFD /* 01-GettingStarted-Bindings-Forms.swift */, + DCC68EE02447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift */, + DC89C4432446111B006900B9 /* 01-GettingStarted-Counter.swift */, + CA3E421E26B8337500581ABC /* 01-GettingStarted-FocusState.swift */, + DCC68EDC2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift */, + CA7BC8ED245CCFE4001FB69F /* 01-GettingStarted-SharedState.swift */, + CAA9ADC12446587C0003A984 /* 02-Effects-Basics.swift */, + CAA9ADC524465C810003A984 /* 02-Effects-Cancellation.swift */, + CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */, + CABC4F3826AEE00C00D5FA2C /* 02-Effects-Refreshable.swift */, + DC89C45424465C44006900B9 /* 02-Effects-Timers.swift */, + CA410EDF247A15FE00E41798 /* 02-Effects-WebSocket.swift */, + CA27C0B6245780CE00CB1E59 /* 02-Effects-SystemEnvironment.swift */, + DC89C45024462DE7006900B9 /* 03-Navigation-Lists-LoadThenNavigate.swift */, + DC89C45224465451006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift */, + DC89C448244618D5006900B9 /* 03-Navigation-LoadThenNavigate.swift */, + DC89C44C244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift */, + DC072321244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift */, + DCC68EAA244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift */, + DCAC2A4E2452352E0094DEF5 /* 04-HigherOrderReducers-ElmLikeSubscriptions.swift */, + CA3E4C5A24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift */, + DCE63B70245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift */, + DCC68EE22447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift */, + DCFE195F278DBF0600C14CCF /* CaseStudiesApp.swift */, + CA5ECF91267A79F0002067FF /* FactClient.swift */, + DC89C41C24460F96006900B9 /* Assets.xcassets */, + CA6AC25F2451131C00C71CB3 /* 04-HigherOrderReducers-ResuableOfflineDownloads */, + DC89C44524461416006900B9 /* Internal */, + ); + path = SwiftUICaseStudies; + sourceTree = ""; + }; + DC89C42C24460F96006900B9 /* SwiftUICaseStudiesTests */ = { + isa = PBXGroup; + children = ( + CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift */, + CA34170724A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift */, + DC27215525BF84FC00D9C8DB /* 01-GettingStarted-BindingBasicsTests.swift */, + 4F5AC11E24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift */, + CAA9ADC324465AB00003A984 /* 02-Effects-BasicsTests.swift */, + CAA9ADC724465D950003A984 /* 02-Effects-CancellationTests.swift */, + CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */, + CABC4F3A26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift */, + DC07231624465D1E003A8B65 /* 02-Effects-TimersTests.swift */, + CA410EE1247C73B400E41798 /* 02-Effects-WebSocketTests.swift */, + CA0C0C4624B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift */, + DC634B242448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift */, + CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */, + ); + path = SwiftUICaseStudiesTests; + sourceTree = ""; + }; + DC89C44524461416006900B9 /* Internal */ = { + isa = PBXGroup; + children = ( + CA6AC2612451135C00C71CB3 /* CircularProgressView.swift */, + DC85EBC2285A731E00431CF3 /* ResignFirstResponder.swift */, + DCC68EDE2447BC810037F998 /* TemplateText.swift */, + DC9EB4162450CBD2005F413B /* UIViewRepresented.swift */, + DCD442C5286CA91F008B4EA7 /* AboutView.swift */, + ); + path = Internal; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + CAF88E6F24B8E26D00539345 /* tvOSCaseStudies */ = { + isa = PBXNativeTarget; + buildConfigurationList = CAF88E8E24B8E26E00539345 /* Build configuration list for PBXNativeTarget "tvOSCaseStudies" */; + buildPhases = ( + CAF88E6C24B8E26D00539345 /* Sources */, + CAF88E6D24B8E26D00539345 /* Frameworks */, + CAF88E6E24B8E26D00539345 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = tvOSCaseStudies; + packageProductDependencies = ( + CAF88E9024B8E3AF00539345 /* ComposableArchitecture */, + ); + productName = tvOSCaseStudies; + productReference = CAF88E7024B8E26D00539345 /* tvOSCaseStudies.app */; + productType = "com.apple.product-type.application"; + }; + CAF88E8224B8E26E00539345 /* tvOSCaseStudiesTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CAF88E8F24B8E26E00539345 /* Build configuration list for PBXNativeTarget "tvOSCaseStudiesTests" */; + buildPhases = ( + CAF88E7F24B8E26E00539345 /* Sources */, + CAF88E8024B8E26E00539345 /* Frameworks */, + CAF88E8124B8E26E00539345 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + CAF88E8524B8E26E00539345 /* PBXTargetDependency */, + ); + name = tvOSCaseStudiesTests; + productName = tvOSCaseStudiesTests; + productReference = CAF88E8324B8E26E00539345 /* tvOSCaseStudiesTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + DC4C6EA62450DD380066A05D /* UIKitCaseStudies */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC4C6EC32450DD390066A05D /* Build configuration list for PBXNativeTarget "UIKitCaseStudies" */; + buildPhases = ( + DC4C6EA32450DD380066A05D /* Sources */, + DC4C6EA42450DD380066A05D /* Frameworks */, + DC4C6EA52450DD380066A05D /* Resources */, + DC4C6ECD2450E0B30066A05D /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = UIKitCaseStudies; + packageProductDependencies = ( + DC13940F2469E27300EE1157 /* ComposableArchitecture */, + ); + productName = UIKitCaseStudies; + productReference = DC4C6EA72450DD380066A05D /* UIKitCaseStudies.app */; + productType = "com.apple.product-type.application"; + }; + DC4C6EBB2450DD390066A05D /* UIKitCaseStudiesTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC4C6EC62450DD390066A05D /* Build configuration list for PBXNativeTarget "UIKitCaseStudiesTests" */; + buildPhases = ( + DC4C6EB82450DD390066A05D /* Sources */, + DC4C6EB92450DD390066A05D /* Frameworks */, + DC4C6EBA2450DD390066A05D /* Resources */, + DC4C6ED32450E0BA0066A05D /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + DC4C6EBE2450DD390066A05D /* PBXTargetDependency */, + ); + name = UIKitCaseStudiesTests; + packageProductDependencies = ( + ); + productName = UIKitCaseStudiesTests; + productReference = DC4C6EBC2450DD390066A05D /* UIKitCaseStudiesTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + DC89C41224460F95006900B9 /* SwiftUICaseStudies */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC89C43224460F96006900B9 /* Build configuration list for PBXNativeTarget "SwiftUICaseStudies" */; + buildPhases = ( + DC89C40F24460F95006900B9 /* Sources */, + DC89C41024460F95006900B9 /* Frameworks */, + DC89C41124460F95006900B9 /* Resources */, + DC89C43D2446106D006900B9 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SwiftUICaseStudies; + packageProductDependencies = ( + DC13940D2469E25C00EE1157 /* ComposableArchitecture */, + ); + productName = CaseStudies; + productReference = DC89C41324460F95006900B9 /* SwiftUICaseStudies.app */; + productType = "com.apple.product-type.application"; + }; + DC89C42824460F96006900B9 /* SwiftUICaseStudiesTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC89C43524460F96006900B9 /* Build configuration list for PBXNativeTarget "SwiftUICaseStudiesTests" */; + buildPhases = ( + DC89C42524460F96006900B9 /* Sources */, + DC89C42624460F96006900B9 /* Frameworks */, + DC89C42724460F96006900B9 /* Resources */, + DC89C44124461077006900B9 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + DC89C42B24460F96006900B9 /* PBXTargetDependency */, + ); + name = SwiftUICaseStudiesTests; + packageProductDependencies = ( + ); + productName = CaseStudiesTests; + productReference = DC89C42924460F96006900B9 /* SwiftUICaseStudiesTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + DC89C40B24460F95006900B9 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1150; + LastUpgradeCheck = 1240; + ORGANIZATIONNAME = "Point-Free"; + TargetAttributes = { + CAF88E6F24B8E26D00539345 = { + CreatedOnToolsVersion = 11.5; + }; + CAF88E8224B8E26E00539345 = { + CreatedOnToolsVersion = 11.5; + TestTargetID = CAF88E6F24B8E26D00539345; + }; + DC4C6EA62450DD380066A05D = { + CreatedOnToolsVersion = 11.4.1; + }; + DC4C6EBB2450DD390066A05D = { + CreatedOnToolsVersion = 11.4.1; + TestTargetID = DC4C6EA62450DD380066A05D; + }; + DC89C41224460F95006900B9 = { + CreatedOnToolsVersion = 11.4; + }; + DC89C42824460F96006900B9 = { + CreatedOnToolsVersion = 11.4; + TestTargetID = DC89C41224460F95006900B9; + }; + }; + }; + buildConfigurationList = DC89C40E24460F95006900B9 /* Build configuration list for PBXProject "CaseStudies" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = DC89C40A24460F95006900B9; + productRefGroup = DC89C41424460F95006900B9 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DC89C41224460F95006900B9 /* SwiftUICaseStudies */, + DC89C42824460F96006900B9 /* SwiftUICaseStudiesTests */, + DC4C6EA62450DD380066A05D /* UIKitCaseStudies */, + DC4C6EBB2450DD390066A05D /* UIKitCaseStudiesTests */, + CAF88E6F24B8E26D00539345 /* tvOSCaseStudies */, + CAF88E8224B8E26E00539345 /* tvOSCaseStudiesTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + CAF88E6E24B8E26D00539345 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CAF88E7724B8E26E00539345 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CAF88E8124B8E26E00539345 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC4C6EA52450DD380066A05D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC4C6EB62450DD380066A05D /* LaunchScreen.storyboard in Resources */, + DC4C6EB32450DD380066A05D /* Preview Assets.xcassets in Resources */, + DC4C6EB02450DD380066A05D /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC4C6EBA2450DD390066A05D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C41124460F95006900B9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC89C41D24460F96006900B9 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C42724460F96006900B9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + CAF88E6C24B8E26D00539345 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CAF88E9324B8E3D000539345 /* Core.swift in Sources */, + CAF88E9524B8E4D500539345 /* FocusView.swift in Sources */, + CAF88E7524B8E26D00539345 /* RootView.swift in Sources */, + CAF88E7324B8E26D00539345 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CAF88E7F24B8E26E00539345 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CAF88E8824B8E26E00539345 /* tvOSCaseStudiesTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC4C6EA32450DD380066A05D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC4C6ED82450E4570066A05D /* UIViewRepresented.swift in Sources */, + DC25DC642450F2DF00082E81 /* ActivityIndicatorViewController.swift in Sources */, + DC4C6ED62450E1050066A05D /* CounterViewController.swift in Sources */, + DC4C6EDA2450E6050066A05D /* NavigateAndLoad.swift in Sources */, + DC4C6EAC2450DD380066A05D /* SceneDelegate.swift in Sources */, + DC25DC612450F2B000082E81 /* LoadThenNavigate.swift in Sources */, + DC25DC5F2450F13200082E81 /* IfLetStoreController.swift in Sources */, + DC4C6EAE2450DD380066A05D /* RootViewController.swift in Sources */, + DC630FDA2451016B00BAECBA /* ListsOfState.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC4C6EB82450DD390066A05D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC4C6EC12450DD390066A05D /* UIKitCaseStudiesTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C40F24460F95006900B9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC89C449244618D5006900B9 /* 03-Navigation-LoadThenNavigate.swift in Sources */, + DCC68EE12447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift in Sources */, + DC072322244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift in Sources */, + DC89C45324465452006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift in Sources */, + CAF069D024ACC5AF00A1AAEF /* 00-Core.swift in Sources */, + DCC68EE32447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift in Sources */, + CA3E421F26B8337500581ABC /* 01-GettingStarted-FocusState.swift in Sources */, + DCC68EDF2447BC810037F998 /* TemplateText.swift in Sources */, + DCAC2A4F2452352E0094DEF5 /* 04-HigherOrderReducers-ElmLikeSubscriptions.swift in Sources */, + CA6AC2672451135C00C71CB3 /* DownloadClient.swift in Sources */, + CAA9ADC624465C810003A984 /* 02-Effects-Cancellation.swift in Sources */, + CA5ECF92267A79F0002067FF /* FactClient.swift in Sources */, + DC9EB4172450CBD2005F413B /* UIViewRepresented.swift in Sources */, + CA6AC2652451135C00C71CB3 /* CircularProgressView.swift in Sources */, + CA6AC2642451135C00C71CB3 /* ReusableComponents-Download.swift in Sources */, + DCFE1960278DBF0600C14CCF /* CaseStudiesApp.swift in Sources */, + DCD442C6286CA91F008B4EA7 /* AboutView.swift in Sources */, + CA6AC2662451135C00C71CB3 /* DownloadComponent.swift in Sources */, + CA3E4C5B24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift in Sources */, + DC5B505125C86EBC000D8DFD /* 01-GettingStarted-Bindings-Forms.swift in Sources */, + CAA9ADC22446587C0003A984 /* 02-Effects-Basics.swift in Sources */, + DC89C41B24460F95006900B9 /* 00-RootView.swift in Sources */, + DCC68EDD2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift in Sources */, + CABC4F3926AEE00C00D5FA2C /* 02-Effects-Refreshable.swift in Sources */, + DC85EBC3285A731E00431CF3 /* ResignFirstResponder.swift in Sources */, + DCC68EAB244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift in Sources */, + CAE962FD24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift in Sources */, + CA25E5D224463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift in Sources */, + DC88D8A6245341EC0077F427 /* 01-GettingStarted-Animations.swift in Sources */, + DC89C44D244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift in Sources */, + DC89C4442446111B006900B9 /* 01-GettingStarted-Counter.swift in Sources */, + DCE63B71245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift in Sources */, + CA27C0B7245780CE00CB1E59 /* 02-Effects-SystemEnvironment.swift in Sources */, + CAA9ADCA2446605B0003A984 /* 02-Effects-LongLiving.swift in Sources */, + DC89C45124462DE7006900B9 /* 03-Navigation-Lists-LoadThenNavigate.swift in Sources */, + DC89C45524465C44006900B9 /* 02-Effects-Timers.swift in Sources */, + CA410EE0247A15FE00E41798 /* 02-Effects-WebSocket.swift in Sources */, + CA7BC8EE245CCFE4001FB69F /* 01-GettingStarted-SharedState.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DC89C42524460F96006900B9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC27215625BF84FC00D9C8DB /* 01-GettingStarted-BindingBasicsTests.swift in Sources */, + CA0C51FB245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift in Sources */, + DC634B252448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift in Sources */, + CAA9ADC824465D950003A984 /* 02-Effects-CancellationTests.swift in Sources */, + CA410EE2247C73B400E41798 /* 02-Effects-WebSocketTests.swift in Sources */, + CABC4F3B26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift in Sources */, + CA34170824A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift in Sources */, + CA0C0C4724B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift in Sources */, + DC07231724465D1E003A8B65 /* 02-Effects-TimersTests.swift in Sources */, + CA50BE6024A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift in Sources */, + CAA9ADC424465AB00003A984 /* 02-Effects-BasicsTests.swift in Sources */, + 4F5AC11F24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift in Sources */, + CAA9ADCC2446615B0003A984 /* 02-Effects-LongLivingTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + CAF88E8524B8E26E00539345 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CAF88E6F24B8E26D00539345 /* tvOSCaseStudies */; + targetProxy = CAF88E8424B8E26E00539345 /* PBXContainerItemProxy */; + }; + DC4C6EBE2450DD390066A05D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DC4C6EA62450DD380066A05D /* UIKitCaseStudies */; + targetProxy = DC4C6EBD2450DD390066A05D /* PBXContainerItemProxy */; + }; + DC89C42B24460F96006900B9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DC89C41224460F95006900B9 /* SwiftUICaseStudies */; + targetProxy = DC89C42A24460F96006900B9 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + DC4C6EB42450DD380066A05D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + DC4C6EB52450DD380066A05D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + CAF88E8A24B8E26E00539345 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + CODE_SIGN_STYLE = Automatic; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = tvOSCaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.tvOSCaseStudies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 13.3; + }; + name = Debug; + }; + CAF88E8B24B8E26E00539345 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; + CODE_SIGN_STYLE = Automatic; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = tvOSCaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.tvOSCaseStudies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 13.3; + }; + name = Release; + }; + CAF88E8C24B8E26E00539345 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = tvOSCaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.tvOSCaseStudiesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/tvOSCaseStudies.app/tvOSCaseStudies"; + TVOS_DEPLOYMENT_TARGET = 13.3; + }; + name = Debug; + }; + CAF88E8D24B8E26E00539345 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = tvOSCaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.tvOSCaseStudiesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/tvOSCaseStudies.app/tvOSCaseStudies"; + TVOS_DEPLOYMENT_TARGET = 13.3; + }; + name = Release; + }; + DC4C6EC42450DD390066A05D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"UIKitCaseStudies/Preview Content\""; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = UIKitCaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.UIKitCaseStudies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DC4C6EC52450DD390066A05D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"UIKitCaseStudies/Preview Content\""; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = UIKitCaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.UIKitCaseStudies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + DC4C6EC72450DD390066A05D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = UIKitCaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.UIKitCaseStudiesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/UIKitCaseStudies.app/UIKitCaseStudies"; + }; + name = Debug; + }; + DC4C6EC82450DD390066A05D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = UIKitCaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.UIKitCaseStudiesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/UIKitCaseStudies.app/UIKitCaseStudies"; + }; + name = Release; + }; + DC89C43024460F96006900B9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + DC89C43124460F96006900B9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + DC89C43324460F96006900B9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = SwiftUICaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUICaseStudies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DC89C43424460F96006900B9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = SwiftUICaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUICaseStudies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + DC89C43624460F96006900B9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = SwiftUICaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUICaseStudiesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftUICaseStudies.app/SwiftUICaseStudies"; + }; + name = Debug; + }; + DC89C43724460F96006900B9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = SwiftUICaseStudies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.SwiftUICaseStudiesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftUICaseStudies.app/SwiftUICaseStudies"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + CAF88E8E24B8E26E00539345 /* Build configuration list for PBXNativeTarget "tvOSCaseStudies" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CAF88E8A24B8E26E00539345 /* Debug */, + CAF88E8B24B8E26E00539345 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CAF88E8F24B8E26E00539345 /* Build configuration list for PBXNativeTarget "tvOSCaseStudiesTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CAF88E8C24B8E26E00539345 /* Debug */, + CAF88E8D24B8E26E00539345 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DC4C6EC32450DD390066A05D /* Build configuration list for PBXNativeTarget "UIKitCaseStudies" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC4C6EC42450DD390066A05D /* Debug */, + DC4C6EC52450DD390066A05D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DC4C6EC62450DD390066A05D /* Build configuration list for PBXNativeTarget "UIKitCaseStudiesTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC4C6EC72450DD390066A05D /* Debug */, + DC4C6EC82450DD390066A05D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DC89C40E24460F95006900B9 /* Build configuration list for PBXProject "CaseStudies" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC89C43024460F96006900B9 /* Debug */, + DC89C43124460F96006900B9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DC89C43224460F96006900B9 /* Build configuration list for PBXNativeTarget "SwiftUICaseStudies" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC89C43324460F96006900B9 /* Debug */, + DC89C43424460F96006900B9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DC89C43524460F96006900B9 /* Build configuration list for PBXNativeTarget "SwiftUICaseStudiesTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC89C43624460F96006900B9 /* Debug */, + DC89C43724460F96006900B9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + CAF88E9024B8E3AF00539345 /* ComposableArchitecture */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitecture; + }; + DC13940D2469E25C00EE1157 /* ComposableArchitecture */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitecture; + }; + DC13940F2469E27300EE1157 /* ComposableArchitecture */ = { + isa = XCSwiftPackageProductDependency; + productName = ComposableArchitecture; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = DC89C40B24460F95006900B9 /* Project object */; +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme new file mode 100644 index 00000000..7cd4593a --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme new file mode 100644 index 00000000..50d8ea9e --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/tvOSCaseStudies.xcscheme b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/tvOSCaseStudies.xcscheme new file mode 100644 index 00000000..15b27510 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/tvOSCaseStudies.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/README.md b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/README.md new file mode 100644 index 00000000..0468b9f7 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/README.md @@ -0,0 +1,3 @@ +# Composable Architecture Case Studies + +This project includes a number of digestible examples of how to solve common problems using the Composable Architecture. diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift new file mode 100644 index 00000000..eea21170 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift @@ -0,0 +1,282 @@ +import Combine +import ComposableArchitecture +import UIKit +import XCTestDynamicOverlay + +struct RootState { + var alertAndConfirmationDialog = AlertAndConfirmationDialogState() + var animation = AnimationsState() + var bindingBasics = BindingBasicsState() + var bindingForm = BindingFormState() + var clock = ClockState() + var counter = CounterState() + var effectsBasics = EffectsBasicsState() + var effectsCancellation = EffectsCancellationState() + var effectsTimers = TimersState() + var episodes = EpisodesState(episodes: .mocks) + var focusDemo = FocusDemoState() + var lifecycle = LifecycleDemoState() + var loadThenNavigate = LoadThenNavigateState() + var loadThenNavigateList = LoadThenNavigateListState() + var loadThenPresent = LoadThenPresentState() + var longLivingEffects = LongLivingEffectsState() + var map = MapAppState(cityMaps: .mocks) + var multipleDependencies = MultipleDependenciesState() + var navigateAndLoad = NavigateAndLoadState() + var navigateAndLoadList = NavigateAndLoadListState() + var nested = NestedState.mock + var optionalBasics = OptionalBasicsState() + var presentAndLoad = PresentAndLoadState() + var refreshable = RefreshableState() + var shared = SharedState() + var timers = TimersState() + var twoCounters = TwoCountersState() + var webSocket = WebSocketState() +} + +enum RootAction { + case alertAndConfirmationDialog(AlertAndConfirmationDialogAction) + case animation(AnimationsAction) + case bindingBasics(BindingBasicsAction) + case bindingForm(BindingFormAction) + case clock(ClockAction) + case counter(CounterAction) + case effectsBasics(EffectsBasicsAction) + case effectsCancellation(EffectsCancellationAction) + case episodes(EpisodesAction) + case focusDemo(FocusDemoAction) + case lifecycle(LifecycleDemoAction) + case loadThenNavigate(LoadThenNavigateAction) + case loadThenNavigateList(LoadThenNavigateListAction) + case loadThenPresent(LoadThenPresentAction) + case longLivingEffects(LongLivingEffectsAction) + case map(MapAppAction) + case multipleDependencies(MultipleDependenciesAction) + case navigateAndLoad(NavigateAndLoadAction) + case navigateAndLoadList(NavigateAndLoadListAction) + case nested(NestedAction) + case optionalBasics(OptionalBasicsAction) + case onAppear + case presentAndLoad(PresentAndLoadAction) + case refreshable(RefreshableAction) + case shared(SharedStateAction) + case timers(TimersAction) + case twoCounters(TwoCountersAction) + case webSocket(WebSocketAction) +} + +struct RootEnvironment { + var date: () -> Date + var downloadClient: DownloadClient + var fact: FactClient + var favorite: (UUID, Bool) -> Effect + var fetchNumber: () -> Effect + var mainQueue: AnySchedulerOf + var notificationCenter: NotificationCenter + var uuid: () -> UUID + var webSocket: WebSocketClient + + static let live = Self( + date: Date.init, + downloadClient: .live, + fact: .live, + favorite: favorite(id:isFavorite:), + fetchNumber: liveFetchNumber, + mainQueue: .main, + notificationCenter: .default, + uuid: UUID.init, + webSocket: .live + ) +} + +let rootReducer = Reducer.combine( + .init { state, action, _ in + switch action { + case .onAppear: + state = .init() + return .none + + default: + return .none + } + }, + alertAndConfirmationDialogReducer + .pullback( + state: \.alertAndConfirmationDialog, + action: /RootAction.alertAndConfirmationDialog, + environment: { _ in .init() } + ), + animationsReducer + .pullback( + state: \.animation, + action: /RootAction.animation, + environment: { .init(mainQueue: $0.mainQueue) } + ), + bindingBasicsReducer + .pullback( + state: \.bindingBasics, + action: /RootAction.bindingBasics, + environment: { _ in .init() } + ), + bindingFormReducer + .pullback( + state: \.bindingForm, + action: /RootAction.bindingForm, + environment: { _ in .init() } + ), + clockReducer + .pullback( + state: \.clock, + action: /RootAction.clock, + environment: { .init(mainQueue: $0.mainQueue) } + ), + counterReducer + .pullback( + state: \.counter, + action: /RootAction.counter, + environment: { _ in .init() } + ), + effectsBasicsReducer + .pullback( + state: \.effectsBasics, + action: /RootAction.effectsBasics, + environment: { .init(fact: $0.fact, mainQueue: $0.mainQueue) } + ), + effectsCancellationReducer + .pullback( + state: \.effectsCancellation, + action: /RootAction.effectsCancellation, + environment: { .init(fact: $0.fact, mainQueue: $0.mainQueue) } + ), + episodesReducer + .pullback( + state: \.episodes, + action: /RootAction.episodes, + environment: { .init(favorite: $0.favorite, mainQueue: $0.mainQueue) } + ), + focusDemoReducer + .pullback( + state: \.focusDemo, + action: /RootAction.focusDemo, + environment: { _ in .init() } + ), + lifecycleDemoReducer + .pullback( + state: \.lifecycle, + action: /RootAction.lifecycle, + environment: { .init(mainQueue: $0.mainQueue) } + ), + loadThenNavigateReducer + .pullback( + state: \.loadThenNavigate, + action: /RootAction.loadThenNavigate, + environment: { .init(mainQueue: $0.mainQueue) } + ), + loadThenNavigateListReducer + .pullback( + state: \.loadThenNavigateList, + action: /RootAction.loadThenNavigateList, + environment: { .init(mainQueue: $0.mainQueue) } + ), + loadThenPresentReducer + .pullback( + state: \.loadThenPresent, + action: /RootAction.loadThenPresent, + environment: { .init(mainQueue: $0.mainQueue) } + ), + longLivingEffectsReducer + .pullback( + state: \.longLivingEffects, + action: /RootAction.longLivingEffects, + environment: { .init(notificationCenter: $0.notificationCenter) } + ), + mapAppReducer + .pullback( + state: \.map, + action: /RootAction.map, + environment: { .init(downloadClient: $0.downloadClient, mainQueue: $0.mainQueue) } + ), + multipleDependenciesReducer + .pullback( + state: \.multipleDependencies, + action: /RootAction.multipleDependencies, + environment: { env in + .init( + date: env.date, + environment: .init(fetchNumber: env.fetchNumber), + mainQueue: env.mainQueue, + uuid: env.uuid + ) + } + ), + navigateAndLoadReducer + .pullback( + state: \.navigateAndLoad, + action: /RootAction.navigateAndLoad, + environment: { .init(mainQueue: $0.mainQueue) } + ), + navigateAndLoadListReducer + .pullback( + state: \.navigateAndLoadList, + action: /RootAction.navigateAndLoadList, + environment: { .init(mainQueue: $0.mainQueue) } + ), + nestedReducer + .pullback( + state: \.nested, + action: /RootAction.nested, + environment: { .init(uuid: $0.uuid) } + ), + optionalBasicsReducer + .pullback( + state: \.optionalBasics, + action: /RootAction.optionalBasics, + environment: { _ in .init() } + ), + presentAndLoadReducer + .pullback( + state: \.presentAndLoad, + action: /RootAction.presentAndLoad, + environment: { .init(mainQueue: $0.mainQueue) } + ), + refreshableReducer + .pullback( + state: \.refreshable, + action: /RootAction.refreshable, + environment: { + .init(fact: $0.fact, mainQueue: $0.mainQueue) + } + ), + sharedStateReducer + .pullback( + state: \.shared, + action: /RootAction.shared, + environment: { _ in () } + ), + timersReducer + .pullback( + state: \.timers, + action: /RootAction.timers, + environment: { .init(mainQueue: $0.mainQueue) } + ), + twoCountersReducer + .pullback( + state: \.twoCounters, + action: /RootAction.twoCounters, + environment: { _ in .init() } + ), + webSocketReducer + .pullback( + state: \.webSocket, + action: /RootAction.webSocket, + environment: { .init(mainQueue: $0.mainQueue, webSocket: $0.webSocket) } + ) +) +.debug() +.signpost() + +private func liveFetchNumber() -> Effect { + Deferred { Just(Int.random(in: 1...1_000)) } + .delay(for: 1, scheduler: DispatchQueue.main) + .eraseToEffect() +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift new file mode 100644 index 00000000..94dd0d4d --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift @@ -0,0 +1,306 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +struct RootView: View { + let store: Store + + var body: some View { + WithViewStore(self.store.stateless) { viewStore in + NavigationView { + Form { + Section(header: Text("Getting started")) { + NavigationLink( + "Basics", + destination: CounterDemoView( + store: self.store.scope( + state: \.counter, + action: RootAction.counter + ) + ) + ) + + NavigationLink( + "Pullback and combine", + destination: TwoCountersView( + store: self.store.scope( + state: \.twoCounters, + action: RootAction.twoCounters + ) + ) + ) + + NavigationLink( + "Bindings", + destination: BindingBasicsView( + store: self.store.scope( + state: \.bindingBasics, + action: RootAction.bindingBasics + ) + ) + ) + + NavigationLink( + "Form bindings", + destination: BindingFormView( + store: self.store.scope( + state: \.bindingForm, + action: RootAction.bindingForm + ) + ) + ) + + NavigationLink( + "Optional state", + destination: OptionalBasicsView( + store: self.store.scope( + state: \.optionalBasics, + action: RootAction.optionalBasics + ) + ) + ) + + NavigationLink( + "Shared state", + destination: SharedStateView( + store: self.store.scope( + state: \.shared, + action: RootAction.shared + ) + ) + ) + + NavigationLink( + "Alerts and Confirmation Dialogs", + destination: AlertAndConfirmationDialogView( + store: self.store.scope( + state: \.alertAndConfirmationDialog, + action: RootAction.alertAndConfirmationDialog + ) + ) + ) + + NavigationLink( + "Focus State", + destination: FocusDemoView( + store: self.store.scope( + state: \.focusDemo, + action: RootAction.focusDemo + ) + ) + ) + + NavigationLink( + "Animations", + destination: AnimationsView( + store: self.store.scope( + state: \.animation, + action: RootAction.animation + ) + ) + ) + } + + Section(header: Text("Effects")) { + NavigationLink( + "Basics", + destination: EffectsBasicsView( + store: self.store.scope( + state: \.effectsBasics, + action: RootAction.effectsBasics + ) + ) + ) + + NavigationLink( + "Cancellation", + destination: EffectsCancellationView( + store: self.store.scope( + state: \.effectsCancellation, + action: RootAction.effectsCancellation) + ) + ) + + NavigationLink( + "Long-living effects", + destination: LongLivingEffectsView( + store: self.store.scope( + state: \.longLivingEffects, + action: RootAction.longLivingEffects + ) + ) + ) + + NavigationLink( + "Refreshable", + destination: RefreshableView( + store: self.store.scope( + state: \.refreshable, + action: RootAction.refreshable + ) + ) + ) + + NavigationLink( + "Timers", + destination: TimersView( + store: self.store.scope( + state: \.timers, + action: RootAction.timers + ) + ) + ) + + NavigationLink( + "System environment", + destination: MultipleDependenciesView( + store: self.store.scope( + state: \.multipleDependencies, + action: RootAction.multipleDependencies + ) + ) + ) + + NavigationLink( + "Web socket", + destination: WebSocketView( + store: self.store.scope( + state: \.webSocket, + action: RootAction.webSocket + ) + ) + ) + } + + Section(header: Text("Navigation")) { + NavigationLink( + "Navigate and load data", + destination: NavigateAndLoadView( + store: self.store.scope( + state: \.navigateAndLoad, + action: RootAction.navigateAndLoad + ) + ) + ) + + NavigationLink( + "Load data then navigate", + destination: LoadThenNavigateView( + store: self.store.scope( + state: \.loadThenNavigate, + action: RootAction.loadThenNavigate + ) + ) + ) + + NavigationLink( + "Lists: Navigate and load data", + destination: NavigateAndLoadListView( + store: self.store.scope( + state: \.navigateAndLoadList, + action: RootAction.navigateAndLoadList + ) + ) + ) + + NavigationLink( + "Lists: Load data then navigate", + destination: LoadThenNavigateListView( + store: self.store.scope( + state: \.loadThenNavigateList, + action: RootAction.loadThenNavigateList + ) + ) + ) + + NavigationLink( + "Sheets: Present and load data", + destination: PresentAndLoadView( + store: self.store.scope( + state: \.presentAndLoad, + action: RootAction.presentAndLoad + ) + ) + ) + + NavigationLink( + "Sheets: Load data then present", + destination: LoadThenPresentView( + store: self.store.scope( + state: \.loadThenPresent, + action: RootAction.loadThenPresent + ) + ) + ) + } + + Section(header: Text("Higher-order reducers")) { + NavigationLink( + "Reusable favoriting component", + destination: EpisodesView( + store: self.store.scope( + state: \.episodes, + action: RootAction.episodes + ) + ) + ) + + NavigationLink( + "Reusable offline download component", + destination: CitiesView( + store: self.store.scope( + state: \.map, + action: RootAction.map + ) + ) + ) + + NavigationLink( + "Lifecycle", + destination: LifecycleDemoView( + store: self.store.scope( + state: \.lifecycle, + action: RootAction.lifecycle + ) + ) + ) + + NavigationLink( + "Elm-like subscriptions", + destination: ClockView( + store: self.store.scope( + state: \.clock, + action: RootAction.clock + ) + ) + ) + + NavigationLink( + "Recursive state and actions", + destination: NestedView( + store: self.store.scope( + state: \.nested, + action: RootAction.nested + ) + ) + ) + } + } + .navigationBarTitle("Case Studies") + .onAppear { viewStore.send(.onAppear) } + } + } + } +} + +struct RootView_Previews: PreviewProvider { + static var previews: some View { + RootView( + store: Store( + initialState: RootState(), + reducer: rootReducer, + environment: .live + ) + ) + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift new file mode 100644 index 00000000..3a88819c --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift @@ -0,0 +1,125 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This demonstrates how to best handle alerts and confirmation dialogs in the Composable \ + Architecture. + + Because the library demands that all data flow through the application in a single direction, we \ + cannot leverage SwiftUI's two-way bindings because they can make changes to state without going \ + through a reducer. This means we can't directly use the standard API to display alerts and sheets. + + However, the library comes with two types, `AlertState` and `ConfirmationDialogState`, which can \ + be constructed from reducers and control whether or not an alert or confirmation dialog is \ + displayed. Further, it automatically handles sending actions when you tap their buttons, which \ + allows you to properly handle their functionality in the reducer rather than in two-way bindings \ + and action closures. + + The benefit of doing this is that you can get full test coverage on how a user interacts with \ + alerts and dialogs in your application + """ + +struct AlertAndConfirmationDialogState: Equatable { + var alert: AlertState? + var confirmationDialog: ConfirmationDialogState? + var count = 0 +} + +enum AlertAndConfirmationDialogAction: Equatable { + case alertButtonTapped + case alertDismissed + case confirmationDialogButtonTapped + case confirmationDialogDismissed + case decrementButtonTapped + case incrementButtonTapped +} + +struct AlertAndConfirmationDialogEnvironment {} + +let alertAndConfirmationDialogReducer = Reducer< + AlertAndConfirmationDialogState, AlertAndConfirmationDialogAction, + AlertAndConfirmationDialogEnvironment +> { state, action, _ in + + switch action { + case .alertButtonTapped: + state.alert = AlertState( + title: TextState("Alert!"), + message: TextState("This is an alert"), + primaryButton: .cancel(TextState("Cancel")), + secondaryButton: .default(TextState("Increment"), action: .send(.incrementButtonTapped)) + ) + return .none + + case .alertDismissed: + state.alert = nil + return .none + + case .confirmationDialogButtonTapped: + state.confirmationDialog = ConfirmationDialogState( + title: TextState("Confirmation dialog"), + message: TextState("This is a confirmation dialog."), + buttons: [ + .cancel(TextState("Cancel")), + .default(TextState("Increment"), action: .send(.incrementButtonTapped)), + .default(TextState("Decrement"), action: .send(.decrementButtonTapped)), + ] + ) + return .none + + case .confirmationDialogDismissed: + state.confirmationDialog = nil + return .none + + case .decrementButtonTapped: + state.alert = AlertState(title: TextState("Decremented!")) + state.count -= 1 + return .none + + case .incrementButtonTapped: + state.alert = AlertState(title: TextState("Incremented!")) + state.count += 1 + return .none + } +} + +struct AlertAndConfirmationDialogView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + Text("Count: \(viewStore.count)") + Button("Alert") { viewStore.send(.alertButtonTapped) } + Button("Confirmation Dialog") { viewStore.send(.confirmationDialogButtonTapped) } + } + } + .navigationBarTitle("Alerts & Dialogs") + .alert( + self.store.scope(state: \.alert), + dismiss: .alertDismissed + ) + .confirmationDialog( + self.store.scope(state: \.confirmationDialog), + dismiss: .confirmationDialogDismissed + ) + } +} + +struct AlertAndConfirmationDialog_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + AlertAndConfirmationDialogView( + store: Store( + initialState: AlertAndConfirmationDialogState(), + reducer: alertAndConfirmationDialogReducer, + environment: AlertAndConfirmationDialogEnvironment() + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift new file mode 100644 index 00000000..ac0f1b77 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift @@ -0,0 +1,186 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how changes to application state can drive animations. Because the \ + `Store` processes actions sent to it synchronously you can typically perform animations \ + in the Composable Architecture just as you would in regular SwiftUI. + + To animate the changes made to state when an action is sent to the store you can pass along an \ + explicit animation, as well, or you can call `viewStore.send` in a `withAnimation` block. + + To animate changes made to state through a binding, use the `.animation` method on `Binding`. + + To animate asynchronous changes made to state via effects, use the `.animation` method provided \ + by the CombineSchedulers library to receive asynchronous actions in an animated fashion. + + Try it out by tapping or dragging anywhere on the screen to move the dot, and by flipping the \ + toggle at the bottom of the screen. + """ + +extension Effect where Failure == Never { + public static func keyFrames( + values: [(output: Output, duration: S.SchedulerTimeType.Stride)], + scheduler: S + ) -> Self { + .concatenate( + values + .enumerated() + .map { index, animationState in + index == 0 + ? Effect(value: animationState.output) + : Just(animationState.output) + .delay(for: values[index - 1].duration, scheduler: scheduler) + .eraseToEffect() + } + ) + } +} + +struct AnimationsState: Equatable { + var alert: AlertState? = nil + var circleCenter = CGPoint(x: 50, y: 50) + var circleColor = Color.white + var isCircleScaled = false +} + +enum AnimationsAction: Equatable { + case circleScaleToggleChanged(Bool) + case dismissAlert + case rainbowButtonTapped + case resetButtonTapped + case resetConfirmationButtonTapped + case setColor(Color) + case tapped(CGPoint) +} + +struct AnimationsEnvironment { + var mainQueue: AnySchedulerOf +} + +let animationsReducer = Reducer { + state, action, environment in + + switch action { + case let .circleScaleToggleChanged(isScaled): + state.isCircleScaled = isScaled + return .none + + case .dismissAlert: + state.alert = nil + return .none + + case .rainbowButtonTapped: + return .keyFrames( + values: [Color.red, .blue, .green, .orange, .pink, .purple, .yellow, .white] + .map { (output: .setColor($0), duration: 1) }, + scheduler: environment.mainQueue.animation(.linear) + ) + + case .resetButtonTapped: + state.alert = AlertState( + title: TextState("Reset state?"), + primaryButton: .destructive( + TextState("Reset"), + action: .send(.resetConfirmationButtonTapped, animation: .default) + ), + secondaryButton: .cancel(TextState("Cancel")) + ) + return .none + + case .resetConfirmationButtonTapped: + state = AnimationsState() + return .none + + case let .setColor(color): + state.circleColor = color + return .none + + case let .tapped(point): + state.circleCenter = point + return .none + } +} + +struct AnimationsView: View { + @Environment(\.colorScheme) var colorScheme + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + GeometryReader { proxy in + VStack(alignment: .leading) { + ZStack(alignment: .center) { + Text(template: readMe, .body) + .padding() + + Circle() + .fill(viewStore.circleColor) + .blendMode(.difference) + .frame(width: 50, height: 50) + .scaleEffect(viewStore.isCircleScaled ? 2 : 1) + .offset( + x: viewStore.circleCenter.x - proxy.size.width / 2, + y: viewStore.circleCenter.y - proxy.size.height / 2 + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(self.colorScheme == .dark ? Color.black : .white) + .simultaneousGesture( + DragGesture(minimumDistance: 0).onChanged { gesture in + viewStore.send( + .tapped(gesture.location), + animation: .interactiveSpring(response: 0.25, dampingFraction: 0.1) + ) + } + ) + Toggle( + "Big mode", + isOn: + viewStore + .binding(get: \.isCircleScaled, send: AnimationsAction.circleScaleToggleChanged) + .animation(.interactiveSpring(response: 0.25, dampingFraction: 0.1)) + ) + .padding() + Button("Rainbow") { viewStore.send(.rainbowButtonTapped, animation: .linear) } + .padding([.horizontal, .bottom]) + Button("Reset") { viewStore.send(.resetButtonTapped) } + .padding([.horizontal, .bottom]) + } + .alert(self.store.scope(state: \.alert), dismiss: .dismissAlert) + } + } + } +} + +struct AnimationsView_Previews: PreviewProvider { + static var previews: some View { + Group { + NavigationView { + AnimationsView( + store: Store( + initialState: AnimationsState(), + reducer: animationsReducer, + environment: AnimationsEnvironment( + mainQueue: .main + ) + ) + ) + } + + NavigationView { + AnimationsView( + store: Store( + initialState: AnimationsState(), + reducer: animationsReducer, + environment: AnimationsEnvironment( + mainQueue: .main + ) + ) + ) + } + .environment(\.colorScheme, .dark) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift new file mode 100644 index 00000000..d710a89b --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift @@ -0,0 +1,141 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This file demonstrates how to handle two-way bindings in the Composable Architecture. + + Two-way bindings in SwiftUI are powerful, but also go against the grain of the "unidirectional \ + data flow" of the Composable Architecture. This is because anything can mutate the value \ + whenever it wants. + + On the other hand, the Composable Architecture demands that mutations can only happen by sending \ + actions to the store, and this means there is only ever one place to see how the state of our \ + feature evolves, which is the reducer. + + Any SwiftUI component that requires a Binding to do its job can be used in the Composable \ + Architecture. You can derive a Binding from your ViewStore by using the `binding` method. This \ + will allow you to specify what state renders the component, and what action to send when the \ + component changes, which means you can keep using a unidirectional style for your feature. + """ + +// The state for this screen holds a bunch of values that will drive +struct BindingBasicsState: Equatable { + var sliderValue = 5.0 + var stepCount = 10 + var text = "" + var toggleIsOn = false +} + +enum BindingBasicsAction { + case sliderValueChanged(Double) + case stepCountChanged(Int) + case textChanged(String) + case toggleChanged(isOn: Bool) +} + +struct BindingBasicsEnvironment {} + +let bindingBasicsReducer = Reducer< + BindingBasicsState, BindingBasicsAction, BindingBasicsEnvironment +> { + state, action, _ in + switch action { + case let .sliderValueChanged(value): + state.sliderValue = value + return .none + + case let .stepCountChanged(count): + state.sliderValue = .minimum(state.sliderValue, Double(count)) + state.stepCount = count + return .none + + case let .textChanged(text): + state.text = text + return .none + + case let .toggleChanged(isOn): + state.toggleIsOn = isOn + return .none + } +} + +struct BindingBasicsView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + HStack { + TextField( + "Type here", + text: viewStore.binding(get: \.text, send: BindingBasicsAction.textChanged) + ) + .disableAutocorrection(true) + .foregroundColor(viewStore.toggleIsOn ? .gray : .primary) + Text(alternate(viewStore.text)) + } + .disabled(viewStore.toggleIsOn) + + Toggle( + isOn: viewStore.binding(get: \.toggleIsOn, send: BindingBasicsAction.toggleChanged) + .resignFirstResponder() + ) { + Text("Disable other controls") + } + + Stepper( + value: viewStore.binding( + get: \.stepCount, send: BindingBasicsAction.stepCountChanged), + in: 0...100 + ) { + Text("Max slider value: \(viewStore.stepCount)") + .font(.body.monospacedDigit()) + } + .disabled(viewStore.toggleIsOn) + + HStack { + Text("Slider value: \(Int(viewStore.sliderValue))") + .font(.body.monospacedDigit()) + Slider( + value: viewStore.binding( + get: \.sliderValue, + send: BindingBasicsAction.sliderValueChanged + ), + in: 0...Double(viewStore.stepCount) + ) + } + .disabled(viewStore.toggleIsOn) + } + } + .navigationBarTitle("Bindings basics") + } +} + +private func alternate(_ string: String) -> String { + string + .enumerated() + .map { idx, char in + idx.isMultiple(of: 2) + ? char.uppercased() + : char.lowercased() + } + .joined() +} + +struct BindingBasicsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BindingBasicsView( + store: Store( + initialState: BindingBasicsState(), + reducer: bindingBasicsReducer, + environment: BindingBasicsEnvironment() + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift new file mode 100644 index 00000000..09128103 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift @@ -0,0 +1,122 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This file demonstrates how to handle two-way bindings in the Composable Architecture using \ + bindable state and actions. + + Bindable state and actions allow you to safely eliminate the boilerplate caused by needing to \ + have a unique action for every UI control. Instead, all UI bindings can be consolidated into a \ + single `binding` action that holds onto a `BindingAction` value, and all bindable state can be \ + safeguarded with the `BindableState` property wrapper. + + It is instructive to compare this case study to the "Binding Basics" case study. + """ + +// The state for this screen holds a bunch of values that will drive +struct BindingFormState: Equatable { + @BindableState var sliderValue = 5.0 + @BindableState var stepCount = 10 + @BindableState var text = "" + @BindableState var toggleIsOn = false +} + +enum BindingFormAction: BindableAction, Equatable { + case binding(BindingAction) + case resetButtonTapped +} + +struct BindingFormEnvironment {} + +let bindingFormReducer = Reducer< + BindingFormState, BindingFormAction, BindingFormEnvironment +> { + state, action, _ in + switch action { + case .binding(\.$stepCount): + state.sliderValue = .minimum(state.sliderValue, Double(state.stepCount)) + return .none + + case .binding: + return .none + + case .resetButtonTapped: + state = BindingFormState() + return .none + } +} +.binding() + +struct BindingFormView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + HStack { + TextField("Type here", text: viewStore.binding(\.$text)) + .disableAutocorrection(true) + .foregroundColor(viewStore.toggleIsOn ? .gray : .primary) + + Text(alternate(viewStore.text)) + } + .disabled(viewStore.toggleIsOn) + + Toggle( + "Disable other controls", + isOn: viewStore.binding(\.$toggleIsOn) + .resignFirstResponder() + ) + + Stepper(value: viewStore.binding(\.$stepCount), in: 0...100) { + Text("Max slider value: \(viewStore.stepCount)") + .font(.body.monospacedDigit()) + } + .disabled(viewStore.toggleIsOn) + + HStack { + Text("Slider value: \(Int(viewStore.sliderValue))") + .font(.body.monospacedDigit()) + + Slider(value: viewStore.binding(\.$sliderValue), in: 0...Double(viewStore.stepCount)) + } + .disabled(viewStore.toggleIsOn) + + Button("Reset") { + viewStore.send(.resetButtonTapped) + } + .foregroundColor(.red) + } + } + .navigationBarTitle("Bindings form") + } +} + +private func alternate(_ string: String) -> String { + string + .enumerated() + .map { idx, char in + idx.isMultiple(of: 2) + ? char.uppercased() + : char.lowercased() + } + .joined() +} + +struct BindingFormView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BindingFormView( + store: Store( + initialState: BindingFormState(), + reducer: bindingFormReducer, + environment: BindingFormEnvironment() + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift new file mode 100644 index 00000000..4a6242fa --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift @@ -0,0 +1,81 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how to take small features and compose them into bigger ones using the \ + `pullback` and `combine` operators on reducers, and the `scope` operator on stores. + + It reuses the the domain of the counter screen and embeds it, twice, in a larger domain. + """ + +struct TwoCountersState: Equatable { + var counter1 = CounterState() + var counter2 = CounterState() +} + +enum TwoCountersAction { + case counter1(CounterAction) + case counter2(CounterAction) +} + +struct TwoCountersEnvironment {} + +let twoCountersReducer = Reducer + .combine( + counterReducer.pullback( + state: \TwoCountersState.counter1, + action: /TwoCountersAction.counter1, + environment: { _ in CounterEnvironment() } + ), + counterReducer.pullback( + state: \TwoCountersState.counter2, + action: /TwoCountersAction.counter2, + environment: { _ in CounterEnvironment() } + ) + ) + +struct TwoCountersView: View { + let store: Store + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + HStack { + Text("Counter 1") + + CounterView( + store: self.store.scope(state: \.counter1, action: TwoCountersAction.counter1) + ) + .buttonStyle(.borderless) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + HStack { + Text("Counter 2") + + CounterView( + store: self.store.scope(state: \.counter2, action: TwoCountersAction.counter2) + ) + .buttonStyle(.borderless) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + + } + } + .navigationBarTitle("Two counter demo") + } +} + +struct TwoCountersView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + TwoCountersView( + store: Store( + initialState: TwoCountersState(), + reducer: twoCountersReducer, + environment: TwoCountersEnvironment() + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift new file mode 100644 index 00000000..ac40717c --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift @@ -0,0 +1,80 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates the basics of the Composable Architecture in an archetypal counter \ + application. + + The domain of the application is modeled using simple data types that correspond to the mutable \ + state of the application and any actions that can affect that state or the outside world. + """ + +struct CounterState: Equatable { + var count = 0 +} + +enum CounterAction: Equatable { + case decrementButtonTapped + case incrementButtonTapped +} + +struct CounterEnvironment {} + +let counterReducer = Reducer { state, action, _ in + switch action { + case .decrementButtonTapped: + state.count -= 1 + return .none + case .incrementButtonTapped: + state.count += 1 + return .none + } +} + +struct CounterView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + HStack { + Button("−") { viewStore.send(.decrementButtonTapped) } + Text("\(viewStore.count)") + .font(.body.monospacedDigit()) + Button("+") { viewStore.send(.incrementButtonTapped) } + } + } + } +} + +struct CounterDemoView: View { + let store: Store + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + Section { + CounterView(store: self.store) + .buttonStyle(.borderless) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationBarTitle("Counter demo") + } +} + +struct CounterView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + CounterDemoView( + store: Store( + initialState: CounterState(), + reducer: counterReducer, + environment: CounterEnvironment() + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift new file mode 100644 index 00000000..9672d33b --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift @@ -0,0 +1,99 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This demonstrates how to make use of SwiftUI's `@FocusState` in the Composable Architecture. \ + If you tap the "Sign in" button while a field is empty, the focus will be changed to that field. + """ + +struct FocusDemoState: Equatable { + @BindableState var focusedField: Field? = nil + @BindableState var password: String = "" + @BindableState var username: String = "" + + enum Field: String, Hashable { + case username, password + } +} + +enum FocusDemoAction: BindableAction, Equatable { + case binding(BindingAction) + case signInButtonTapped +} + +struct FocusDemoEnvironment {} + +let focusDemoReducer = Reducer< + FocusDemoState, + FocusDemoAction, + FocusDemoEnvironment +> { state, action, _ in + switch action { + case .binding: + return .none + + case .signInButtonTapped: + if state.username.isEmpty { + state.focusedField = .username + } else if state.password.isEmpty { + state.focusedField = .password + } + return .none + } +} +.binding() + +struct FocusDemoView: View { + let store: Store + @FocusState var focusedField: FocusDemoState.Field? + + var body: some View { + WithViewStore(self.store) { viewStore in + VStack(alignment: .leading, spacing: 32) { + Text(template: readMe, .caption) + + VStack { + TextField("Username", text: viewStore.binding(\.$username)) + .focused($focusedField, equals: .username) + + SecureField("Password", text: viewStore.binding(\.$password)) + .focused($focusedField, equals: .password) + + Button("Sign In") { + viewStore.send(.signInButtonTapped) + } + } + + Spacer() + } + .padding() + .synchronize(viewStore.binding(\.$focusedField), self.$focusedField) + } + .navigationBarTitle("Focus demo") + } +} + +extension View { + func synchronize( + _ first: Binding, + _ second: FocusState.Binding + ) -> some View { + self + .onChange(of: first.wrappedValue) { second.wrappedValue = $0 } + .onChange(of: second.wrappedValue) { first.wrappedValue = $0 } + } +} + +struct FocusDemo_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + FocusDemoView( + store: Store( + initialState: FocusDemoState(), + reducer: focusDemoReducer, + environment: FocusDemoEnvironment() + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift new file mode 100644 index 00000000..53e7a1c9 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift @@ -0,0 +1,111 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how to show and hide views based on the presence of some optional child \ + state. + + The parent state holds a `CounterState?` value. When it is `nil` we will default to a plain text \ + view. But when it is non-`nil` we will show a view fragment for a counter that operates on the \ + non-optional counter state. + + Tapping "Toggle counter state" will flip between the `nil` and non-`nil` counter states. + """ + +struct OptionalBasicsState: Equatable { + var optionalCounter: CounterState? +} + +enum OptionalBasicsAction: Equatable { + case optionalCounter(CounterAction) + case toggleCounterButtonTapped +} + +struct OptionalBasicsEnvironment {} + +let optionalBasicsReducer = + counterReducer + .optional() + .pullback( + state: \.optionalCounter, + action: /OptionalBasicsAction.optionalCounter, + environment: { _ in CounterEnvironment() } + ) + .combined( + with: Reducer< + OptionalBasicsState, OptionalBasicsAction, OptionalBasicsEnvironment + > { state, action, environment in + switch action { + case .toggleCounterButtonTapped: + state.optionalCounter = + state.optionalCounter == nil + ? CounterState() + : nil + return .none + case .optionalCounter: + return .none + } + } + ) + +struct OptionalBasicsView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + Button("Toggle counter state") { + viewStore.send(.toggleCounterButtonTapped) + } + + IfLetStore( + self.store.scope( + state: \.optionalCounter, + action: OptionalBasicsAction.optionalCounter + ), + then: { store in + VStack(alignment: .leading, spacing: 16) { + Text(template: "`CounterState` is non-`nil`") + CounterView(store: store) + .buttonStyle(.borderless) + } + }, + else: { + Text(template: "`CounterState` is `nil`") + } + ) + } + } + .navigationBarTitle("Optional state") + } +} + +struct OptionalBasicsView_Previews: PreviewProvider { + static var previews: some View { + Group { + NavigationView { + OptionalBasicsView( + store: Store( + initialState: OptionalBasicsState(), + reducer: optionalBasicsReducer, + environment: OptionalBasicsEnvironment() + ) + ) + } + + NavigationView { + OptionalBasicsView( + store: Store( + initialState: OptionalBasicsState(optionalCounter: CounterState(count: 42)), + reducer: optionalBasicsReducer, + environment: OptionalBasicsEnvironment() + ) + ) + } + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift new file mode 100644 index 00000000..37d95d73 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift @@ -0,0 +1,272 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how multiple independent screens can share state in the Composable \ + Architecture. Each tab manages its own state, and could be in separate modules, but changes in \ + one tab are immediately reflected in the other. + + This tab has its own state, consisting of a count value that can be incremented and decremented, \ + as well as an alert value that is set when asking if the current count is prime. + + Internally, it is also keeping track of various stats, such as min and max counts and total \ + number of count events that occurred. Those states are viewable in the other tab, and the stats \ + can be reset from the other tab. + """ + +struct SharedState: Equatable { + var counter = CounterState() + var currentTab = Tab.counter + + enum Tab { case counter, profile } + + struct CounterState: Equatable { + var alert: AlertState? + var count = 0 + var maxCount = 0 + var minCount = 0 + var numberOfCounts = 0 + } + + // The ProfileState can be derived from the CounterState by getting and setting the parts it cares + // about. This allows the profile feature to operate on a subset of app state instead of the whole + // thing. + var profile: ProfileState { + get { + ProfileState( + currentTab: self.currentTab, + count: self.counter.count, + maxCount: self.counter.maxCount, + minCount: self.counter.minCount, + numberOfCounts: self.counter.numberOfCounts + ) + } + set { + self.currentTab = newValue.currentTab + self.counter.count = newValue.count + self.counter.maxCount = newValue.maxCount + self.counter.minCount = newValue.minCount + self.counter.numberOfCounts = newValue.numberOfCounts + } + } + + struct ProfileState: Equatable { + private(set) var currentTab: Tab + private(set) var count = 0 + private(set) var maxCount: Int + private(set) var minCount: Int + private(set) var numberOfCounts: Int + + fileprivate mutating func resetCount() { + self.currentTab = .counter + self.count = 0 + self.maxCount = 0 + self.minCount = 0 + self.numberOfCounts = 0 + } + } +} + +enum SharedStateAction: Equatable { + case counter(CounterAction) + case profile(ProfileAction) + case selectTab(SharedState.Tab) + + enum CounterAction: Equatable { + case alertDismissed + case decrementButtonTapped + case incrementButtonTapped + case isPrimeButtonTapped + } + + enum ProfileAction: Equatable { + case resetCounterButtonTapped + } +} + +let sharedStateCounterReducer = Reducer< + SharedState.CounterState, SharedStateAction.CounterAction, Void +> { state, action, _ in + switch action { + case .alertDismissed: + state.alert = nil + return .none + + case .decrementButtonTapped: + state.count -= 1 + state.numberOfCounts += 1 + state.minCount = min(state.minCount, state.count) + return .none + + case .incrementButtonTapped: + state.count += 1 + state.numberOfCounts += 1 + state.maxCount = max(state.maxCount, state.count) + return .none + + case .isPrimeButtonTapped: + state.alert = AlertState( + title: TextState( + isPrime(state.count) + ? "👍 The number \(state.count) is prime!" + : "👎 The number \(state.count) is not prime :(" + ) + ) + return .none + } +} + +let sharedStateProfileReducer = Reducer< + SharedState.ProfileState, SharedStateAction.ProfileAction, Void +> { state, action, _ in + switch action { + case .resetCounterButtonTapped: + state.resetCount() + return .none + } +} + +let sharedStateReducer = Reducer.combine( + sharedStateCounterReducer.pullback( + state: \SharedState.counter, + action: /SharedStateAction.counter, + environment: { _ in () } + ), + sharedStateProfileReducer.pullback( + state: \SharedState.profile, + action: /SharedStateAction.profile, + environment: { _ in () } + ), + Reducer { state, action, _ in + switch action { + case .counter, .profile: + return .none + case let .selectTab(tab): + state.currentTab = tab + return .none + } + } +) + +struct SharedStateView: View { + let store: Store + + var body: some View { + WithViewStore(self.store.scope(state: \.currentTab)) { viewStore in + VStack { + Picker( + "Tab", + selection: viewStore.binding(send: SharedStateAction.selectTab) + ) { + Text("Counter") + .tag(SharedState.Tab.counter) + + Text("Profile") + .tag(SharedState.Tab.profile) + } + .pickerStyle(.segmented) + + if viewStore.state == .counter { + SharedStateCounterView( + store: self.store.scope(state: \.counter, action: SharedStateAction.counter)) + } + + if viewStore.state == .profile { + SharedStateProfileView( + store: self.store.scope(state: \.profile, action: SharedStateAction.profile)) + } + + Spacer() + } + } + .padding() + } +} + +struct SharedStateCounterView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + VStack(spacing: 64) { + Text(template: readMe, .caption) + + VStack(spacing: 16) { + HStack { + Button("−") { viewStore.send(.decrementButtonTapped) } + + Text("\(viewStore.count)") + .font(.body.monospacedDigit()) + + Button("+") { viewStore.send(.incrementButtonTapped) } + } + + Button("Is this prime?") { viewStore.send(.isPrimeButtonTapped) } + } + } + .padding(16) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .top) + .navigationBarTitle("Shared State Demo") + .alert(self.store.scope(state: \.alert), dismiss: .alertDismissed) + } + } +} + +struct SharedStateProfileView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + VStack(spacing: 64) { + Text( + template: """ + This tab shows state from the previous tab, and it is capable of reseting all of the \ + state back to 0. + + This shows that it is possible for each screen to model its state in the way that makes \ + the most sense for it, while still allowing the state and mutations to be shared \ + across independent screens. + """, + .caption + ) + + VStack(spacing: 16) { + Text("Current count: \(viewStore.count)") + Text("Max count: \(viewStore.maxCount)") + Text("Min count: \(viewStore.minCount)") + Text("Total number of count events: \(viewStore.numberOfCounts)") + Button("Reset") { viewStore.send(.resetCounterButtonTapped) } + } + } + .padding(16) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .top) + .navigationBarTitle("Profile") + } + } +} + +// MARK: - SwiftUI previews + +struct SharedState_Previews: PreviewProvider { + static var previews: some View { + SharedStateView( + store: Store( + initialState: SharedState(), + reducer: sharedStateReducer, + environment: () + ) + ) + } +} + +// MARK: - Private helpers + +/// Checks if a number is prime or not. +private func isPrime(_ p: Int) -> Bool { + if p <= 1 { return false } + if p <= 3 { return true } + for i in 2...Int(sqrtf(Float(p))) { + if p % i == 0 { return false } + } + return true +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift new file mode 100644 index 00000000..2a1d9d41 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift @@ -0,0 +1,165 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how to introduce side effects into a feature built with the \ + Composable Architecture. + + A side effect is a unit of work that needs to be performed in the outside world. For example, an \ + API request needs to reach an external service over HTTP, which brings with it lots of \ + uncertainty and complexity. + + Many things we do in our applications involve side effects, such as timers, database requests, \ + file access, socket connections, and anytime a scheduler is involved (such as debouncing, \ + throttling and delaying), and they are typically difficult to test. + + This application has a simple side effect: tapping "Number fact" will trigger an API request to \ + load a piece of trivia about that number. This effect is handled by the reducer, and a full test \ + suite is written to confirm that the effect behaves in the way we expect. + """ + +// MARK: - Feature domain + +struct EffectsBasicsState: Equatable { + var count = 0 + var isNumberFactRequestInFlight = false + var numberFact: String? +} + +enum EffectsBasicsAction: Equatable { + case decrementButtonTapped + case incrementButtonTapped + case numberFactButtonTapped + case numberFactResponse(Result) +} + +struct EffectsBasicsEnvironment { + var fact: FactClient + var mainQueue: AnySchedulerOf +} + +// MARK: - Feature business logic + +let effectsBasicsReducer = Reducer< + EffectsBasicsState, + EffectsBasicsAction, + EffectsBasicsEnvironment +> { state, action, environment in + switch action { + case .decrementButtonTapped: + state.count -= 1 + state.numberFact = nil + return .none + + case .incrementButtonTapped: + state.count += 1 + state.numberFact = nil + return .none + + case .numberFactButtonTapped: + state.isNumberFactRequestInFlight = true + state.numberFact = nil + // Return an effect that fetches a number fact from the API and returns the + // value back to the reducer's `numberFactResponse` action. + + return Effect.task { [count = state.count] in + do { + return .numberFactResponse( + .success( + try await environment.fact.fetchAsync(count) + ) + ) + } catch { + return .numberFactResponse(.failure(FactClient.Failure())) + } + } + +// return environment.fact.fetch(state.count) +// .map { fact in fact + "!!!" } +// .receive(on: environment.mainQueue) +// .catchToEffect(EffectsBasicsAction.numberFactResponse) + + case let .numberFactResponse(.success(response)): + state.isNumberFactRequestInFlight = false + state.numberFact = response + return .none + + case .numberFactResponse(.failure): + // NB: This is where we could handle the error is some way, such as showing an alert. + state.isNumberFactRequestInFlight = false + return .none + } +} + +// MARK: - Feature view + +struct EffectsBasicsView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + Section { + HStack { + Spacer() + Button("−") { viewStore.send(.decrementButtonTapped) } + Text("\(viewStore.count)") + .font(.body.monospacedDigit()) + Button("+") { viewStore.send(.incrementButtonTapped) } + Spacer() + } + .buttonStyle(.borderless) + + Button("Number fact") { viewStore.send(.numberFactButtonTapped) } + .frame(maxWidth: .infinity) + + if viewStore.isNumberFactRequestInFlight { + ProgressView() + .frame(maxWidth: .infinity) + // NB: There seems to be a bug in SwiftUI where the progress view does not show + // a second time unless it is given a new identity. + .id(UUID()) + } + + if let numberFact = viewStore.numberFact { + Text(numberFact) + } + } + + Section { + Button("Number facts provided by numbersapi.com") { + UIApplication.shared.open(URL(string: "http://numbersapi.com")!) + } + .foregroundColor(.gray) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderless) + } + .navigationBarTitle("Effects") + } +} + +// MARK: - Feature SwiftUI previews + +struct EffectsBasicsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EffectsBasicsView( + store: Store( + initialState: EffectsBasicsState(), + reducer: effectsBasicsReducer, + environment: EffectsBasicsEnvironment( + fact: .live, + mainQueue: .main + ) + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift new file mode 100644 index 00000000..4c57249b --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift @@ -0,0 +1,145 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how one can cancel in-flight effects in the Composable Architecture. + + Use the stepper to count to a number, and then tap the "Number fact" button to fetch \ + a random fact about that number using an API. + + While the API request is in-flight, you can tap "Cancel" to cancel the effect and prevent \ + it from feeding data back into the application. Interacting with the stepper while a \ + request is in-flight will also cancel it. + """ + +// MARK: - Demo app domain + +struct EffectsCancellationState: Equatable { + var count = 0 + var currentTrivia: String? + var isTriviaRequestInFlight = false +} + +enum EffectsCancellationAction: Equatable { + case cancelButtonTapped + case stepperChanged(Int) + case triviaButtonTapped + case triviaResponse(Result) +} + +struct EffectsCancellationEnvironment { + var fact: FactClient + var mainQueue: AnySchedulerOf +} + +// MARK: - Business logic + +let effectsCancellationReducer = Reducer< + EffectsCancellationState, EffectsCancellationAction, EffectsCancellationEnvironment +> { state, action, environment in + + enum TriviaRequestId {} + + switch action { + case .cancelButtonTapped: + state.isTriviaRequestInFlight = false + return .cancel(id: TriviaRequestId.self) + + case let .stepperChanged(value): + state.count = value + state.currentTrivia = nil + state.isTriviaRequestInFlight = false + return .cancel(id: TriviaRequestId.self) + + case .triviaButtonTapped: + state.currentTrivia = nil + state.isTriviaRequestInFlight = true + + return environment.fact.fetch(state.count) + .receive(on: environment.mainQueue) + .catchToEffect(EffectsCancellationAction.triviaResponse) + .cancellable(id: TriviaRequestId.self) + + case let .triviaResponse(.success(response)): + state.isTriviaRequestInFlight = false + state.currentTrivia = response + return .none + + case .triviaResponse(.failure): + state.isTriviaRequestInFlight = false + return .none + } +} + +// MARK: - Application view + +struct EffectsCancellationView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + Section { + Stepper( + value: viewStore.binding( + get: \.count, send: EffectsCancellationAction.stepperChanged) + ) { + Text("\(viewStore.count)") + } + + if viewStore.isTriviaRequestInFlight { + HStack { + Button("Cancel") { viewStore.send(.cancelButtonTapped) } + Spacer() + ProgressView() + // NB: There seems to be a bug in SwiftUI where the progress view does not show + // a second time unless it is given a new identity. + .id(UUID()) + } + } else { + Button("Number fact") { viewStore.send(.triviaButtonTapped) } + .disabled(viewStore.isTriviaRequestInFlight) + } + + viewStore.currentTrivia.map { + Text($0).padding(.vertical, 8) + } + } + + Section { + Button("Number facts provided by numbersapi.com") { + UIApplication.shared.open(URL(string: "http://numbersapi.com")!) + } + .foregroundColor(.gray) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderless) + } + .navigationBarTitle("Effect cancellation") + } +} + +// MARK: - SwiftUI previews + +struct EffectsCancellation_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EffectsCancellationView( + store: Store( + initialState: EffectsCancellationState(), + reducer: effectsCancellationReducer, + environment: EffectsCancellationEnvironment( + fact: .live, + mainQueue: .main + ) + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift new file mode 100644 index 00000000..e4d56e65 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift @@ -0,0 +1,116 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This application demonstrates how to handle long-living effects, for example notifications from \ + Notification Center. + + Run this application in the simulator, and take a few screenshots by going to \ + *Device › Screenshot* in the menu, and observe that the UI counts the number of times that \ + happens. + + Then, navigate to another screen and take screenshots there, and observe that this screen does \ + *not* count those screenshots. + """ + +// MARK: - Application domain + +struct LongLivingEffectsState: Equatable { + var screenshotCount = 0 +} + +enum LongLivingEffectsAction { + case userDidTakeScreenshotNotification + case onAppear + case onDisappear +} + +struct LongLivingEffectsEnvironment { + var notificationCenter: NotificationCenter +} + +// MARK: - Business logic + +let longLivingEffectsReducer = Reducer< + LongLivingEffectsState, LongLivingEffectsAction, LongLivingEffectsEnvironment +> { state, action, environment in + + enum UserDidTakeScreenshotNotificationId {} + + switch action { + case .userDidTakeScreenshotNotification: + state.screenshotCount += 1 + return .none + + case .onAppear: + // When the view appears, start the effect that emits when screenshots are taken. + return environment.notificationCenter + .publisher(for: UIApplication.userDidTakeScreenshotNotification) + .eraseToEffect { _ in LongLivingEffectsAction.userDidTakeScreenshotNotification } + .cancellable(id: UserDidTakeScreenshotNotificationId.self) + + case .onDisappear: + // When view disappears, stop the effect. + return .cancel(id: UserDidTakeScreenshotNotificationId.self) + } +} + +// MARK: - SwiftUI view + +struct LongLivingEffectsView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + Text("A screenshot of this screen has been taken \(viewStore.screenshotCount) times.") + .font(.headline) + + Section { + NavigationLink(destination: self.detailView) { + Text("Navigate to another screen") + } + } + } + .navigationBarTitle("Long-living effects") + .onAppear { viewStore.send(.onAppear) } + .onDisappear { viewStore.send(.onDisappear) } + } + } + + var detailView: some View { + Text( + """ + Take a screenshot of this screen a few times, and then go back to the previous screen to see \ + that those screenshots were not counted. + """ + ) + .padding(.horizontal, 64) + } +} + +// MARK: - SwiftUI previews + +struct EffectsLongLiving_Previews: PreviewProvider { + static var previews: some View { + let appView = LongLivingEffectsView( + store: Store( + initialState: LongLivingEffectsState(), + reducer: longLivingEffectsReducer, + environment: LongLivingEffectsEnvironment( + notificationCenter: .default + ) + ) + ) + + return Group { + NavigationView { appView } + NavigationView { appView.detailView } + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift new file mode 100644 index 00000000..f5ddd97d --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift @@ -0,0 +1,121 @@ +import ComposableArchitecture +import SwiftUI + +private var readMe = """ + This application demonstrates how to make use of SwiftUI's `refreshable` API in the Composable \ + Architecture. Use the "-" and "+" buttons to count up and down, and then pull down to request \ + a fact about that number. + + There is an overload of the `.send` method that allows you to suspend and await while a piece \ + of state is true. You can use this method to communicate to SwiftUI that you are \ + currently fetching data so that it knows to continue showing the loading indicator. + """ + +struct RefreshableState: Equatable { + var count = 0 + var fact: String? + var isLoading = false +} + +enum RefreshableAction: Equatable { + case cancelButtonTapped + case decrementButtonTapped + case factResponse(Result) + case incrementButtonTapped + case refresh +} + +struct RefreshableEnvironment { + var fact: FactClient + var mainQueue: AnySchedulerOf +} + +let refreshableReducer = Reducer< + RefreshableState, + RefreshableAction, + RefreshableEnvironment +> { state, action, environment in + + enum CancelId {} + + switch action { + case .cancelButtonTapped: + state.isLoading = false + return .cancel(id: CancelId.self) + + case .decrementButtonTapped: + state.count -= 1 + return .none + + case let .factResponse(.success(fact)): + state.isLoading = false + state.fact = fact + return .none + + case .factResponse(.failure): + state.isLoading = false + // TODO: do some error handling + return .none + + case .incrementButtonTapped: + state.count += 1 + return .none + + case .refresh: + state.fact = nil + state.isLoading = true + return environment.fact.fetch(state.count) + .delay(for: .seconds(2), scheduler: environment.mainQueue.animation()) + .catchToEffect(RefreshableAction.factResponse) + .cancellable(id: CancelId.self) + } +} + +struct RefreshableView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + List { + Section { + AboutView(readMe: readMe) + } + + HStack { + Button("-") { viewStore.send(.decrementButtonTapped) } + Text("\(viewStore.count)") + Button("+") { viewStore.send(.incrementButtonTapped) } + } + .buttonStyle(.plain) + + if let fact = viewStore.fact { + Text(fact) + .bold() + } + if viewStore.isLoading { + Button("Cancel") { + viewStore.send(.cancelButtonTapped, animation: .default) + } + } + } + .refreshable { + await viewStore.send(.refresh, while: \.isLoading) + } + } + } +} + +struct Refreshable_Previews: PreviewProvider { + static var previews: some View { + RefreshableView( + store: Store( + initialState: RefreshableState(), + reducer: refreshableReducer, + environment: RefreshableEnvironment( + fact: .live, + mainQueue: .main + ) + ) + ) + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift new file mode 100644 index 00000000..18b623f0 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift @@ -0,0 +1,234 @@ +import ComposableArchitecture +import Foundation +import SwiftUI + +private let readMe = """ + This screen demonstrates how one can share system-wide dependencies across many features with \ + very little work. The idea is to create a `SystemEnvironment` generic type that wraps an \ + environment, and then implement dynamic member lookup so that you can seamlessly use the \ + dependencies in both environments. + + Then, throughout your application you can wrap your environments in the `SystemEnvironment` \ + to get instant access to all of the shared dependencies. Some good candidates for dependencies \ + to share are things like date initializers, schedulers (especially `DispatchQueue.main`), `UUID` \ + initializers, and any other dependency in your application that you want every reducer to have \ + access to. + """ + +struct MultipleDependenciesState: Equatable { + var alert: AlertState? + var dateString: String? + var fetchedNumberString: String? + var isFetchInFlight = false + var uuidString: String? +} + +enum MultipleDependenciesAction: Equatable { + case alertButtonTapped + case alertDelayReceived + case alertDismissed + case dateButtonTapped + case fetchNumberButtonTapped + case fetchNumberResponse(Int) + case uuidButtonTapped +} + +struct MultipleDependenciesEnvironment { + var fetchNumber: () -> Effect +} + +let multipleDependenciesReducer = Reducer< + MultipleDependenciesState, + MultipleDependenciesAction, + SystemEnvironment +> { state, action, environment in + + switch action { + case .alertButtonTapped: + return Effect(value: .alertDelayReceived) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + + case .alertDelayReceived: + state.alert = AlertState(title: TextState("Here's an alert after a delay!")) + return .none + + case .alertDismissed: + state.alert = nil + return .none + + case .dateButtonTapped: + state.dateString = "\(environment.date())" + return .none + + case .fetchNumberButtonTapped: + state.isFetchInFlight = true + return environment.fetchNumber() + .map(MultipleDependenciesAction.fetchNumberResponse) + + case let .fetchNumberResponse(number): + state.isFetchInFlight = false + state.fetchedNumberString = "\(number)" + return .none + + case .uuidButtonTapped: + state.uuidString = "\(environment.uuid())" + return .none + } +} + +struct MultipleDependenciesView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section( + header: Text(template: readMe, .caption) + ) { + EmptyView() + } + + Section( + header: Text( + template: """ + The actions below make use of the dependencies in the `SystemEnvironment`. + """, .caption) + ) { + HStack { + Button("Date") { viewStore.send(.dateButtonTapped) } + if let dateString = viewStore.dateString { + Text(dateString) + } + } + + HStack { + Button("UUID") { viewStore.send(.uuidButtonTapped) } + if let uuidString = viewStore.uuidString { + Text(uuidString) + } + } + + Button("Delayed Alert") { viewStore.send(.alertButtonTapped) } + .alert(self.store.scope(state: \.alert), dismiss: .alertDismissed) + } + + Section( + header: Text( + template: """ + The actions below make use of the custom environment for this screen, which holds a \ + dependency for fetching a random number. + """, .caption) + ) { + HStack { + Button("Fetch Number") { viewStore.send(.fetchNumberButtonTapped) } + if let fetchedNumberString = viewStore.fetchedNumberString { + Text(fetchedNumberString) + } + + Spacer() + + if viewStore.isFetchInFlight { + ProgressView() + } + } + } + } + .buttonStyle(.borderless) + } + .navigationBarTitle("System Environment") + } +} + +struct MultipleDependenciesView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + MultipleDependenciesView( + store: Store( + initialState: MultipleDependenciesState(), + reducer: multipleDependenciesReducer, + environment: .live( + environment: MultipleDependenciesEnvironment( + fetchNumber: { + Effect(value: Int.random(in: 1...1_000)) + .delay(for: 1, scheduler: DispatchQueue.main) + .eraseToEffect() + } + ) + ) + ) + ) + } + } +} + +@dynamicMemberLookup +struct SystemEnvironment { + var date: () -> Date + var environment: Environment + var mainQueue: AnySchedulerOf + var uuid: () -> UUID + + subscript( + dynamicMember keyPath: WritableKeyPath + ) -> Dependency { + get { self.environment[keyPath: keyPath] } + set { self.environment[keyPath: keyPath] = newValue } + } + + /// Creates a live system environment with the wrapped environment provided. + /// + /// - Parameter environment: An environment to be wrapped in the system environment. + /// - Returns: A new system environment. + static func live(environment: Environment) -> Self { + Self( + date: Date.init, + environment: environment, + mainQueue: .main, + uuid: UUID.init + ) + } + + /// Transforms the underlying wrapped environment. + func map( + _ transform: @escaping (Environment) -> NewEnvironment + ) -> SystemEnvironment { + .init( + date: self.date, + environment: transform(self.environment), + mainQueue: self.mainQueue, + uuid: self.uuid + ) + } +} + +#if DEBUG + import XCTestDynamicOverlay + + extension SystemEnvironment { + static func unimplemented( + date: @escaping () -> Date = XCTUnimplemented("\(Self.self).date", placeholder: Date()), + environment: Environment, + mainQueue: AnySchedulerOf = .unimplemented, + uuid: @escaping () -> UUID = XCTUnimplemented("\(Self.self).uuid", placeholder: UUID()) + ) -> Self { + Self( + date: date, + environment: environment, + mainQueue: mainQueue, + uuid: uuid + ) + } + } +#endif + +extension UUID { + /// A deterministic, auto-incrementing "UUID" generator for testing. + static var incrementing: () -> UUID { + var uuid = 0 + return { + defer { uuid += 1 } + return UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012x", uuid))")! + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift new file mode 100644 index 00000000..8f61d1aa --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift @@ -0,0 +1,134 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This application demonstrates how to work with timers in the Composable Architecture. + + Although the Combine framework comes with a `Timer.publisher` API, and it is possible to use \ + that API in the Composable Architecture, it is not easy to test. That is why we have provided an \ + `Effect.timer` API that works with schedulers and can be tested. + """ + +// MARK: - Timer feature domain + +struct TimersState: Equatable { + var isTimerActive = false + var secondsElapsed = 0 +} + +enum TimersAction { + case timerTicked + case toggleTimerButtonTapped +} + +struct TimersEnvironment { + var mainQueue: AnySchedulerOf +} + +let timersReducer = Reducer { + state, action, environment in + + enum TimerId {} + + switch action { + case .timerTicked: + state.secondsElapsed += 1 + return .none + + case .toggleTimerButtonTapped: + state.isTimerActive.toggle() + return state.isTimerActive + ? Effect.timer( + id: TimerId.self, + every: 1, + tolerance: .zero, + on: environment.mainQueue.animation(.interpolatingSpring(stiffness: 3000, damping: 40)) + ) + .map { _ in TimersAction.timerTicked } + : .cancel(id: TimerId.self) + } +} + +// MARK: - Timer feature view + +struct TimersView: View { + let store: Store + + var body: some View { + WithViewStore(store) { viewStore in + VStack { + Text(template: readMe, .body) + + ZStack { + Circle() + .fill( + AngularGradient( + gradient: Gradient( + colors: [ + .blue.opacity(0.3), + .blue, + .blue, + .green, + .green, + .yellow, + .yellow, + .red, + .red, + .purple, + .purple, + .purple.opacity(0.3), + ] + ), + center: .center + ) + ) + .rotationEffect(.degrees(-90)) + + GeometryReader { proxy in + Path { path in + path.move(to: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)) + path.addLine(to: CGPoint(x: proxy.size.width / 2, y: 0)) + } + .stroke(Color.black, lineWidth: 3) + .rotationEffect(.degrees(Double(viewStore.secondsElapsed) * 360 / 60)) + } + } + .frame(width: 280, height: 280) + .padding(.bottom, 16) + + Button(action: { viewStore.send(.toggleTimerButtonTapped) }) { + HStack { + Text(viewStore.isTimerActive ? "Stop" : "Start") + } + .foregroundColor(.white) + .padding() + .background(viewStore.isTimerActive ? Color.red : .blue) + .cornerRadius(16) + } + + Spacer() + } + .padding() + .navigationBarTitle("Timers") + } + } +} + +// MARK: - SwiftUI previews + +struct TimersView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + TimersView( + store: Store( + initialState: TimersState(), + reducer: timersReducer, + environment: TimersEnvironment( + mainQueue: .main + ) + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift new file mode 100644 index 00000000..45f453b0 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift @@ -0,0 +1,364 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This application demonstrates how to work with a web socket in the Composable Architecture. + + A lightweight wrapper is made for `URLSession`'s API for web sockets so that we can send, \ + receive and ping a socket endpoint. To test, connect to the socket server, and then send a \ + message. The socket server should immediately reply with the exact message you send it. + """ + +struct WebSocketState: Equatable { + var alert: AlertState? + var connectivityState = ConnectivityState.disconnected + var messageToSend = "" + var receivedMessages: [String] = [] + + enum ConnectivityState: String { + case connected + case connecting + case disconnected + } +} + +enum WebSocketAction: Equatable { + case alertDismissed + case connectButtonTapped + case messageToSendChanged(String) + case pingResponse(NSError?) + case receivedSocketMessage(Result) + case sendButtonTapped + case sendResponse(NSError?) + case webSocket(WebSocketClient.Action) +} + +struct WebSocketEnvironment { + var mainQueue: AnySchedulerOf + var webSocket: WebSocketClient +} + +let webSocketReducer = Reducer { + state, action, environment in + + struct WebSocketId: Hashable {} + + var receiveSocketMessageEffect: Effect { + return environment.webSocket.receive(WebSocketId()) + .receive(on: environment.mainQueue) + .catchToEffect(WebSocketAction.receivedSocketMessage) + .cancellable(id: WebSocketId()) + } + var sendPingEffect: Effect { + return environment.webSocket.sendPing(WebSocketId()) + .delay(for: 10, scheduler: environment.mainQueue) + .eraseToEffect(WebSocketAction.pingResponse) + .cancellable(id: WebSocketId()) + } + + switch action { + case .alertDismissed: + state.alert = nil + return .none + + case .connectButtonTapped: + switch state.connectivityState { + case .connected, .connecting: + state.connectivityState = .disconnected + return .cancel(id: WebSocketId()) + + case .disconnected: + state.connectivityState = .connecting + return environment.webSocket.open( + WebSocketId(), URL(string: "wss://echo.websocket.events")!, [] + ) + .receive(on: environment.mainQueue) + .eraseToEffect(WebSocketAction.webSocket) + .cancellable(id: WebSocketId()) + } + + case let .messageToSendChanged(message): + state.messageToSend = message + return .none + + case .pingResponse: + // Ping the socket again in 10 seconds + return sendPingEffect + + case let .receivedSocketMessage(.success(.string(string))): + state.receivedMessages.append(string) + + // Immediately ask for the next socket message + return receiveSocketMessageEffect + + case .receivedSocketMessage(.success): + // Immediately ask for the next socket message + return receiveSocketMessageEffect + + case .receivedSocketMessage(.failure): + return .none + + case .sendButtonTapped: + let messageToSend = state.messageToSend + state.messageToSend = "" + + return environment.webSocket.send(WebSocketId(), .string(messageToSend)) + .receive(on: environment.mainQueue) + .eraseToEffect(WebSocketAction.sendResponse) + + case let .sendResponse(error): + if error != nil { + state.alert = AlertState(title: TextState("Could not send socket message. Try again.")) + } + return .none + + case let .webSocket(.didClose(code, _)): + state.connectivityState = .disconnected + return .cancel(id: WebSocketId()) + + case let .webSocket(.didBecomeInvalidWithError(error)), + let .webSocket(.didCompleteWithError(error)): + state.connectivityState = .disconnected + if error != nil { + state.alert = AlertState( + title: TextState("Disconnected from socket for some reason. Try again.") + ) + } + return .cancel(id: WebSocketId()) + + case .webSocket(.didOpenWithProtocol): + state.connectivityState = .connected + return .merge( + receiveSocketMessageEffect, + sendPingEffect + ) + } +} + +struct WebSocketView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + VStack(alignment: .leading) { + AboutView(readMe: readMe) + .padding(.bottom) + + HStack { + TextField( + "Message to send", + text: viewStore.binding( + get: \.messageToSend, send: WebSocketAction.messageToSendChanged) + ) + + Button( + viewStore.connectivityState == .connected + ? "Disconnect" + : viewStore.connectivityState == .disconnected + ? "Connect" + : "Connecting..." + ) { + viewStore.send(.connectButtonTapped) + } + } + + Button("Send message") { + viewStore.send(.sendButtonTapped) + } + + Spacer() + + Text("Status: \(viewStore.connectivityState.rawValue)") + .foregroundColor(.secondary) + Text("Received messages:") + .foregroundColor(.secondary) + Text(viewStore.receivedMessages.joined(separator: "\n")) + } + .padding() + .alert(self.store.scope(state: \.alert), dismiss: .alertDismissed) + .navigationBarTitle("Web Socket") + } + } +} + +// MARK: - WebSocketClient + +struct WebSocketClient { + enum Action: Equatable { + case didBecomeInvalidWithError(NSError?) + case didClose(code: URLSessionWebSocketTask.CloseCode, reason: Data?) + case didCompleteWithError(NSError?) + case didOpenWithProtocol(String?) + } + + enum Message: Equatable { + case data(Data) + case string(String) + + init?(_ message: URLSessionWebSocketTask.Message) { + switch message { + case let .data(data): + self = .data(data) + case let .string(string): + self = .string(string) + @unknown default: + return nil + } + } + + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.data(lhs), .data(rhs)): + return lhs == rhs + case let (.string(lhs), .string(rhs)): + return lhs == rhs + case (.data, _), (.string, _): + return false + } + } + } + + var cancel: (AnyHashable, URLSessionWebSocketTask.CloseCode, Data?) -> Effect + var open: (AnyHashable, URL, [String]) -> Effect + var receive: (AnyHashable) -> Effect + var send: (AnyHashable, URLSessionWebSocketTask.Message) -> Effect + var sendPing: (AnyHashable) -> Effect +} + +extension WebSocketClient { + static let live = WebSocketClient( + cancel: { id, closeCode, reason in + .fireAndForget { + dependencies[id]?.task.cancel(with: closeCode, reason: reason) + dependencies[id]?.subscriber.send(completion: .finished) + dependencies[id] = nil + } + }, + open: { id, url, protocols in + Effect.run { subscriber in + let delegate = WebSocketDelegate( + didBecomeInvalidWithError: { + subscriber.send(.didBecomeInvalidWithError($0 as NSError?)) + }, + didClose: { + subscriber.send(.didClose(code: $0, reason: $1)) + }, + didCompleteWithError: { + subscriber.send(.didCompleteWithError($0 as NSError?)) + }, + didOpenWithProtocol: { + subscriber.send(.didOpenWithProtocol($0)) + } + ) + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + let task = session.webSocketTask(with: url, protocols: protocols) + task.resume() + dependencies[id] = Dependencies(delegate: delegate, subscriber: subscriber, task: task) + return AnyCancellable { + task.cancel(with: .normalClosure, reason: nil) + dependencies[id]?.subscriber.send(completion: .finished) + dependencies[id] = nil + } + } + }, + receive: { id in + .future { callback in + dependencies[id]?.task.receive { result in + switch result.map(Message.init) { + case let .success(.some(message)): + callback(.success(message)) + case .success(.none): + callback(.failure(NSError(domain: "co.pointfree", code: 1))) + case let .failure(error): + callback(.failure(error as NSError)) + } + } + } + }, + send: { id, message in + .future { callback in + dependencies[id]?.task.send(message) { error in + callback(.success(error as NSError?)) + } + } + }, + sendPing: { id in + .future { callback in + dependencies[id]?.task.sendPing { error in + callback(.success(error as NSError?)) + } + } + } + ) +} + +private var dependencies: [AnyHashable: Dependencies] = [:] +private struct Dependencies { + let delegate: URLSessionWebSocketDelegate + let subscriber: Effect.Subscriber + let task: URLSessionWebSocketTask +} + +private class WebSocketDelegate: NSObject, URLSessionWebSocketDelegate { + let didBecomeInvalidWithError: (Error?) -> Void + let didClose: (URLSessionWebSocketTask.CloseCode, Data?) -> Void + let didCompleteWithError: (Error?) -> Void + let didOpenWithProtocol: (String?) -> Void + + init( + didBecomeInvalidWithError: @escaping (Error?) -> Void, + didClose: @escaping (URLSessionWebSocketTask.CloseCode, Data?) -> Void, + didCompleteWithError: @escaping (Error?) -> Void, + didOpenWithProtocol: @escaping (String?) -> Void + ) { + self.didBecomeInvalidWithError = didBecomeInvalidWithError + self.didOpenWithProtocol = didOpenWithProtocol + self.didCompleteWithError = didCompleteWithError + self.didClose = didClose + } + + func urlSession( + _ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String? + ) { + self.didOpenWithProtocol(`protocol`) + } + + func urlSession( + _ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data? + ) { + self.didClose(closeCode, reason) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + self.didCompleteWithError(error) + } + + func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { + self.didBecomeInvalidWithError(error) + } +} + +// MARK: - SwiftUI previews + +struct WebSocketView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + WebSocketView( + store: Store( + initialState: WebSocketState(receivedMessages: ["Echo"]), + reducer: webSocketReducer, + environment: WebSocketEnvironment( + mainQueue: .main, + webSocket: .live + ) + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift new file mode 100644 index 00000000..384ddbe9 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-LoadThenNavigate.swift @@ -0,0 +1,155 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates navigation that depends on loading optional state from a list element. + + Tapping a row fires off an effect that will load its associated counter state a second later. \ + When the counter state is present, you will be programmatically navigated to the screen that \ + depends on this data. + """ + +struct LoadThenNavigateListState: Equatable { + var rows: IdentifiedArrayOf = [ + Row(count: 1, id: UUID()), + Row(count: 42, id: UUID()), + Row(count: 100, id: UUID()), + ] + var selection: Identified? + + struct Row: Equatable, Identifiable { + var count: Int + let id: UUID + var isActivityIndicatorVisible = false + } +} + +enum LoadThenNavigateListAction: Equatable { + case counter(CounterAction) + case onDisappear + case setNavigation(selection: UUID?) + case setNavigationSelectionDelayCompleted(UUID) +} + +struct LoadThenNavigateListEnvironment { + var mainQueue: AnySchedulerOf +} + +let loadThenNavigateListReducer = + counterReducer + .pullback( + state: \Identified.value, + action: .self, + environment: { $0 } + ) + .optional() + .pullback( + state: \LoadThenNavigateListState.selection, + action: /LoadThenNavigateListAction.counter, + environment: { _ in CounterEnvironment() } + ) + .combined( + with: Reducer< + LoadThenNavigateListState, LoadThenNavigateListAction, LoadThenNavigateListEnvironment + > { state, action, environment in + + enum CancelId {} + + switch action { + case .counter: + return .none + + case .onDisappear: + return .cancel(id: CancelId.self) + + case let .setNavigation(selection: .some(navigatedId)): + for row in state.rows { + state.rows[id: row.id]?.isActivityIndicatorVisible = row.id == navigatedId + } + + return Effect(value: .setNavigationSelectionDelayCompleted(navigatedId)) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + .cancellable(id: CancelId.self, cancelInFlight: true) + + case .setNavigation(selection: .none): + if let selection = state.selection { + state.rows[id: selection.id]?.count = selection.count + } + state.selection = nil + return .cancel(id: CancelId.self) + + case let .setNavigationSelectionDelayCompleted(id): + state.rows[id: id]?.isActivityIndicatorVisible = false + state.selection = Identified( + CounterState(count: state.rows[id: id]?.count ?? 0), + id: id + ) + return .none + } + } + ) + +struct LoadThenNavigateListView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + ForEach(viewStore.rows) { row in + NavigationLink( + destination: IfLetStore( + self.store.scope( + state: \.selection?.value, + action: LoadThenNavigateListAction.counter + ) + ) { + CounterView(store: $0) + }, + tag: row.id, + selection: viewStore.binding( + get: \.selection?.id, + send: LoadThenNavigateListAction.setNavigation(selection:) + ) + ) { + HStack { + Text("Load optional counter that starts from \(row.count)") + if row.isActivityIndicatorVisible { + Spacer() + ProgressView() + } + } + } + } + } + .navigationBarTitle("Load then navigate") + .onDisappear { viewStore.send(.onDisappear) } + } + } +} + +struct LoadThenNavigateListView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + LoadThenNavigateListView( + store: Store( + initialState: LoadThenNavigateListState( + rows: [ + LoadThenNavigateListState.Row(count: 1, id: UUID()), + LoadThenNavigateListState.Row(count: 42, id: UUID()), + LoadThenNavigateListState.Row(count: 100, id: UUID()), + ] + ), + reducer: loadThenNavigateListReducer, + environment: LoadThenNavigateListEnvironment( + mainQueue: .main + ) + ) + ) + } + .navigationViewStyle(.stack) + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift new file mode 100644 index 00000000..1aa7daa2 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift @@ -0,0 +1,136 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates navigation that depends on loading optional state from a list element. + + Tapping a row simultaneously navigates to a screen that depends on its associated counter state \ + and fires off an effect that will load this state a second later. + """ + +struct NavigateAndLoadListState: Equatable { + var rows: IdentifiedArrayOf = [ + Row(count: 1, id: UUID()), + Row(count: 42, id: UUID()), + Row(count: 100, id: UUID()), + ] + var selection: Identified? + + struct Row: Equatable, Identifiable { + var count: Int + let id: UUID + } +} + +enum NavigateAndLoadListAction: Equatable { + case counter(CounterAction) + case setNavigation(selection: UUID?) + case setNavigationSelectionDelayCompleted +} + +struct NavigateAndLoadListEnvironment { + var mainQueue: AnySchedulerOf +} + +let navigateAndLoadListReducer = + counterReducer + .optional() + .pullback(state: \Identified.value, action: .self, environment: { $0 }) + .optional() + .pullback( + state: \NavigateAndLoadListState.selection, + action: /NavigateAndLoadListAction.counter, + environment: { _ in CounterEnvironment() } + ) + .combined( + with: Reducer< + NavigateAndLoadListState, NavigateAndLoadListAction, NavigateAndLoadListEnvironment + > { state, action, environment in + + enum CancelId {} + + switch action { + case .counter: + return .none + + case let .setNavigation(selection: .some(id)): + state.selection = Identified(nil, id: id) + + return Effect(value: .setNavigationSelectionDelayCompleted) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + .cancellable(id: CancelId.self) + + case .setNavigation(selection: .none): + if let selection = state.selection, let count = selection.value?.count { + state.rows[id: selection.id]?.count = count + } + state.selection = nil + return .cancel(id: CancelId.self) + + case .setNavigationSelectionDelayCompleted: + guard let id = state.selection?.id else { return .none } + state.selection?.value = CounterState(count: state.rows[id: id]?.count ?? 0) + return .none + } + } + ) + +struct NavigateAndLoadListView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + ForEach(viewStore.rows) { row in + NavigationLink( + destination: IfLetStore( + self.store.scope( + state: \.selection?.value, + action: NavigateAndLoadListAction.counter + ) + ) { + CounterView(store: $0) + } else: { + ProgressView() + }, + tag: row.id, + selection: viewStore.binding( + get: \.selection?.id, + send: NavigateAndLoadListAction.setNavigation(selection:) + ) + ) { + Text("Load optional counter that starts from \(row.count)") + } + } + } + } + .navigationBarTitle("Navigate and load") + } +} + +struct NavigateAndLoadListView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + NavigateAndLoadListView( + store: Store( + initialState: NavigateAndLoadListState( + rows: [ + NavigateAndLoadListState.Row(count: 1, id: UUID()), + NavigateAndLoadListState.Row(count: 42, id: UUID()), + NavigateAndLoadListState.Row(count: 100, id: UUID()), + ] + ), + reducer: navigateAndLoadListReducer, + environment: NavigateAndLoadListEnvironment( + mainQueue: .main + ) + ) + ) + } + .navigationViewStyle(.stack) + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-LoadThenNavigate.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-LoadThenNavigate.swift new file mode 100644 index 00000000..1aca5648 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-LoadThenNavigate.swift @@ -0,0 +1,124 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates navigation that depends on loading optional state. + + Tapping "Load optional counter" fires off an effect that will load the counter state a second \ + later. When the counter state is present, you will be programmatically navigated to the screen \ + that depends on this data. + """ + +struct LoadThenNavigateState: Equatable { + var optionalCounter: CounterState? + var isActivityIndicatorVisible = false + + var isNavigationActive: Bool { self.optionalCounter != nil } +} + +enum LoadThenNavigateAction: Equatable { + case onDisappear + case optionalCounter(CounterAction) + case setNavigation(isActive: Bool) + case setNavigationIsActiveDelayCompleted +} + +struct LoadThenNavigateEnvironment { + var mainQueue: AnySchedulerOf +} + +let loadThenNavigateReducer = + counterReducer + .optional() + .pullback( + state: \.optionalCounter, + action: /LoadThenNavigateAction.optionalCounter, + environment: { _ in CounterEnvironment() } + ) + .combined( + with: Reducer< + LoadThenNavigateState, LoadThenNavigateAction, LoadThenNavigateEnvironment + > { state, action, environment in + + enum CancelId {} + + switch action { + case .onDisappear: + return .cancel(id: CancelId.self) + + case .setNavigation(isActive: true): + state.isActivityIndicatorVisible = true + return Effect(value: .setNavigationIsActiveDelayCompleted) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + .cancellable(id: CancelId.self) + + case .setNavigation(isActive: false): + state.optionalCounter = nil + return .none + + case .setNavigationIsActiveDelayCompleted: + state.isActivityIndicatorVisible = false + state.optionalCounter = CounterState() + return .none + + case .optionalCounter: + return .none + } + } + ) + +struct LoadThenNavigateView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + NavigationLink( + destination: IfLetStore( + self.store.scope( + state: \.optionalCounter, + action: LoadThenNavigateAction.optionalCounter + ) + ) { + CounterView(store: $0) + }, + isActive: viewStore.binding( + get: \.isNavigationActive, + send: LoadThenNavigateAction.setNavigation(isActive:) + ) + ) { + HStack { + Text("Load optional counter") + if viewStore.isActivityIndicatorVisible { + Spacer() + ProgressView() + } + } + } + } + .onDisappear { viewStore.send(.onDisappear) } + } + .navigationBarTitle("Load then navigate") + } +} + +struct LoadThenNavigateView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + LoadThenNavigateView( + store: Store( + initialState: LoadThenNavigateState(), + reducer: loadThenNavigateReducer, + environment: LoadThenNavigateEnvironment( + mainQueue: .main + ) + ) + ) + } + .navigationViewStyle(.stack) + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift new file mode 100644 index 00000000..5318a1f3 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift @@ -0,0 +1,116 @@ +import Combine +import ComposableArchitecture +import SwiftUI +import UIKit + +private let readMe = """ + This screen demonstrates navigation that depends on loading optional state. + + Tapping "Load optional counter" simultaneously navigates to a screen that depends on optional \ + counter state and fires off an effect that will load this state a second later. + """ + +struct NavigateAndLoadState: Equatable { + var isNavigationActive = false + var optionalCounter: CounterState? +} + +enum NavigateAndLoadAction: Equatable { + case optionalCounter(CounterAction) + case setNavigation(isActive: Bool) + case setNavigationIsActiveDelayCompleted +} + +struct NavigateAndLoadEnvironment { + var mainQueue: AnySchedulerOf +} + +let navigateAndLoadReducer = + counterReducer + .optional() + .pullback( + state: \.optionalCounter, + action: /NavigateAndLoadAction.optionalCounter, + environment: { _ in CounterEnvironment() } + ) + .combined( + with: Reducer< + NavigateAndLoadState, NavigateAndLoadAction, NavigateAndLoadEnvironment + > { state, action, environment in + + enum CancelId {} + + switch action { + case .setNavigation(isActive: true): + state.isNavigationActive = true + return Effect(value: .setNavigationIsActiveDelayCompleted) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + .cancellable(id: CancelId.self) + + case .setNavigation(isActive: false): + state.isNavigationActive = false + state.optionalCounter = nil + return .cancel(id: CancelId.self) + + case .setNavigationIsActiveDelayCompleted: + state.optionalCounter = CounterState() + return .none + + case .optionalCounter: + return .none + } + } + ) + +struct NavigateAndLoadView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + NavigationLink( + destination: IfLetStore( + self.store.scope( + state: \.optionalCounter, + action: NavigateAndLoadAction.optionalCounter + ) + ) { + CounterView(store: $0) + } else: { + ProgressView() + }, + isActive: viewStore.binding( + get: \.isNavigationActive, + send: NavigateAndLoadAction.setNavigation(isActive:) + ) + ) { + HStack { + Text("Load optional counter") + } + } + } + } + .navigationBarTitle("Navigate and load") + } +} + +struct NavigateAndLoadView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + NavigateAndLoadView( + store: Store( + initialState: NavigateAndLoadState(), + reducer: navigateAndLoadReducer, + environment: NavigateAndLoadEnvironment( + mainQueue: .main + ) + ) + ) + } + .navigationViewStyle(.stack) + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift new file mode 100644 index 00000000..fe36455e --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift @@ -0,0 +1,125 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates navigation that depends on loading optional data into state. + + Tapping "Load optional counter" fires off an effect that will load the counter state a second \ + later. When the counter state is present, you will be programmatically presented a sheet that \ + depends on this data. + """ + +struct LoadThenPresentState: Equatable { + var optionalCounter: CounterState? + var isActivityIndicatorVisible = false + + var isSheetPresented: Bool { self.optionalCounter != nil } +} + +enum LoadThenPresentAction { + case onDisappear + case optionalCounter(CounterAction) + case setSheet(isPresented: Bool) + case setSheetIsPresentedDelayCompleted +} + +struct LoadThenPresentEnvironment { + var mainQueue: AnySchedulerOf +} + +let loadThenPresentReducer = + counterReducer + .optional() + .pullback( + state: \.optionalCounter, + action: /LoadThenPresentAction.optionalCounter, + environment: { _ in CounterEnvironment() } + ) + .combined( + with: Reducer< + LoadThenPresentState, LoadThenPresentAction, LoadThenPresentEnvironment + > { state, action, environment in + + enum CancelId {} + + switch action { + case .onDisappear: + return .cancel(id: CancelId.self) + + case .setSheet(isPresented: true): + state.isActivityIndicatorVisible = true + return Effect(value: .setSheetIsPresentedDelayCompleted) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + .cancellable(id: CancelId.self) + + case .setSheet(isPresented: false): + state.optionalCounter = nil + return .none + + case .setSheetIsPresentedDelayCompleted: + state.isActivityIndicatorVisible = false + state.optionalCounter = CounterState() + return .none + + case .optionalCounter: + return .none + } + } + ) + +struct LoadThenPresentView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + Button(action: { viewStore.send(.setSheet(isPresented: true)) }) { + HStack { + Text("Load optional counter") + if viewStore.isActivityIndicatorVisible { + Spacer() + ProgressView() + } + } + } + } + .sheet( + isPresented: viewStore.binding( + get: \.isSheetPresented, + send: LoadThenPresentAction.setSheet(isPresented:) + ) + ) { + IfLetStore( + self.store.scope( + state: \.optionalCounter, + action: LoadThenPresentAction.optionalCounter + ) + ) { + CounterView(store: $0) + } + } + .navigationBarTitle("Load and present") + .onDisappear { viewStore.send(.onDisappear) } + } + } +} + +struct LoadThenPresentView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + LoadThenPresentView( + store: Store( + initialState: LoadThenPresentState(), + reducer: loadThenPresentReducer, + environment: LoadThenPresentEnvironment( + mainQueue: .main + ) + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift new file mode 100644 index 00000000..47b5c94f --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift @@ -0,0 +1,113 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates navigation that depends on loading optional data into state. + + Tapping "Load optional counter" simultaneously presents a sheet that depends on optional counter \ + state and fires off an effect that will load this state a second later. + """ + +struct PresentAndLoadState: Equatable { + var optionalCounter: CounterState? + var isSheetPresented = false +} + +enum PresentAndLoadAction { + case optionalCounter(CounterAction) + case setSheet(isPresented: Bool) + case setSheetIsPresentedDelayCompleted +} + +struct PresentAndLoadEnvironment { + var mainQueue: AnySchedulerOf +} + +let presentAndLoadReducer = + counterReducer + .optional() + .pullback( + state: \.optionalCounter, + action: /PresentAndLoadAction.optionalCounter, + environment: { _ in CounterEnvironment() } + ) + .combined( + with: Reducer< + PresentAndLoadState, PresentAndLoadAction, PresentAndLoadEnvironment + > { state, action, environment in + + enum CancelId {} + + switch action { + case .setSheet(isPresented: true): + state.isSheetPresented = true + return Effect(value: .setSheetIsPresentedDelayCompleted) + .delay(for: 1, scheduler: environment.mainQueue) + .eraseToEffect() + .cancellable(id: CancelId.self) + + case .setSheet(isPresented: false): + state.isSheetPresented = false + state.optionalCounter = nil + return .cancel(id: CancelId.self) + + case .setSheetIsPresentedDelayCompleted: + state.optionalCounter = CounterState() + return .none + + case .optionalCounter: + return .none + } + } + ) + +struct PresentAndLoadView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + Button("Load optional counter") { + viewStore.send(.setSheet(isPresented: true)) + } + } + .sheet( + isPresented: viewStore.binding( + get: \.isSheetPresented, + send: PresentAndLoadAction.setSheet(isPresented:) + ) + ) { + IfLetStore( + self.store.scope( + state: \.optionalCounter, + action: PresentAndLoadAction.optionalCounter + ) + ) { + CounterView(store: $0) + } else: { + ProgressView() + } + } + .navigationBarTitle("Present and load") + } + } +} + +struct PresentAndLoadView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + PresentAndLoadView( + store: Store( + initialState: PresentAndLoadState(), + reducer: presentAndLoadReducer, + environment: PresentAndLoadEnvironment( + mainQueue: .main + ) + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ElmLikeSubscriptions.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ElmLikeSubscriptions.swift new file mode 100644 index 00000000..d48f68a6 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ElmLikeSubscriptions.swift @@ -0,0 +1,156 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how the `Reducer` struct can be extended to enhance reducers with \ + extra functionality. + + In this example we introduce a declarative interface for describing long-running effects, \ + inspired by Elm's `subscriptions` API. + """ + +extension Reducer { + static func subscriptions( + _ subscriptions: @escaping (State, Environment) -> [AnyHashable: Effect] + ) -> Self { + var activeSubscriptions: [AnyHashable: Effect] = [:] + + return Reducer { state, _, environment in + let currentSubscriptions = subscriptions(state, environment) + defer { activeSubscriptions = currentSubscriptions } + return .merge( + Set(activeSubscriptions.keys).union(currentSubscriptions.keys).map { id in + switch (activeSubscriptions[id], currentSubscriptions[id]) { + case (.some, .none): + return .cancel(id: id) + case let (.none, .some(effect)): + return effect.cancellable(id: id) + default: + return .none + } + } + ) + } + } +} + +struct ClockState: Equatable { + var isTimerActive = false + var secondsElapsed = 0 +} + +enum ClockAction: Equatable { + case timerTicked + case toggleTimerButtonTapped +} + +struct ClockEnvironment { + var mainQueue: AnySchedulerOf +} + +let clockReducer = Reducer.combine( + Reducer { state, action, environment in + switch action { + case .timerTicked: + state.secondsElapsed += 1 + return .none + case .toggleTimerButtonTapped: + state.isTimerActive.toggle() + return .none + } + }, + .subscriptions { state, environment in + struct TimerId: Hashable {} + guard state.isTimerActive else { return [:] } + return [ + TimerId(): + Effect + .timer( + id: TimerId(), + every: 1, + tolerance: .zero, + on: environment.mainQueue.animation(.interpolatingSpring(stiffness: 3000, damping: 40)) + ) + .map { _ in .timerTicked } + ] + } +) + +struct ClockView: View { + let store: Store + + var body: some View { + WithViewStore(store) { viewStore in + VStack { + Text(template: readMe, .body) + + ZStack { + Circle() + .fill( + AngularGradient( + gradient: Gradient( + colors: [ + .blue.opacity(0.3), + .blue, + .blue, + .green, + .green, + .yellow, + .yellow, + .red, + .red, + .purple, + .purple, + .purple.opacity(0.3), + ] + ), + center: .center + ) + ) + .rotationEffect(.degrees(-90)) + + GeometryReader { proxy in + Path { path in + path.move(to: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2)) + path.addLine(to: CGPoint(x: proxy.size.width / 2, y: 0)) + } + .stroke(Color.black, lineWidth: 3) + .rotationEffect(.degrees(Double(viewStore.secondsElapsed) * 360 / 60)) + } + } + .frame(width: 280, height: 280) + .padding(.bottom, 64) + + Button(action: { viewStore.send(.toggleTimerButtonTapped) }) { + HStack { + Text(viewStore.isTimerActive ? "Stop" : "Start") + } + .foregroundColor(.white) + .padding() + .background(viewStore.isTimerActive ? Color.red : .blue) + .cornerRadius(16) + } + + Spacer() + } + .padding() + .navigationBarTitle("Elm-like subscriptions") + } + } +} + +struct Subscriptions_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + ClockView( + store: Store( + initialState: ClockState(), + reducer: clockReducer, + environment: ClockEnvironment( + mainQueue: .main + ) + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Lifecycle.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Lifecycle.swift new file mode 100644 index 00000000..49fefd16 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Lifecycle.swift @@ -0,0 +1,170 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This demonstrates how to trigger effects when a view appears, and cancel effects when a view \ + disappears. This can be helpful for starting up a feature's long living effects, such as timers, \ + location managers, etc. when that feature is first presented. + + To accomplish this we define a higher-order reducer that enhances any reducer with two additional \ + actions, `.onAppear` and `.onDisappear`, and a way to automate running effects when those actions \ + are sent to the store. + """ + +extension Reducer { + public func lifecycle( + onAppear: @escaping (Environment) -> Effect, + onDisappear: @escaping (Environment) -> Effect + ) -> Reducer, Environment> { + + return .init { state, lifecycleAction, environment in + switch lifecycleAction { + case .onAppear: + return onAppear(environment).map(LifecycleAction.action) + + case .onDisappear: + return onDisappear(environment).fireAndForget() + + case let .action(action): + guard state != nil else { return .none } + return self.run(&state!, action, environment) + .map(LifecycleAction.action) + } + } + } +} + +public enum LifecycleAction { + case onAppear + case onDisappear + case action(Action) +} + +extension LifecycleAction: Equatable where Action: Equatable {} + +struct LifecycleDemoState: Equatable { + var count: Int? +} + +enum LifecycleDemoAction: Equatable { + case timer(LifecycleAction) + case toggleTimerButtonTapped +} + +struct LifecycleDemoEnvironment { + var mainQueue: AnySchedulerOf +} + +let lifecycleDemoReducer: + Reducer = .combine( + timerReducer.pullback( + state: \.count, + action: /LifecycleDemoAction.timer, + environment: { TimerEnvironment(mainQueue: $0.mainQueue) } + ), + Reducer { state, action, environment in + switch action { + case .timer: + return .none + + case .toggleTimerButtonTapped: + state.count = state.count == nil ? 0 : nil + return .none + } + } + ) + +struct LifecycleDemoView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + Button("Toggle Timer") { viewStore.send(.toggleTimerButtonTapped) } + + IfLetStore(self.store.scope(state: \.count, action: LifecycleDemoAction.timer)) { + TimerView(store: $0) + } + } + } + .navigationBarTitle("Lifecycle") + } +} + +private enum TimerId {} + +enum TimerAction { + case decrementButtonTapped + case incrementButtonTapped + case tick +} + +struct TimerEnvironment { + var mainQueue: AnySchedulerOf +} + +private let timerReducer = Reducer { + state, action, TimerEnvironment in + switch action { + case .decrementButtonTapped: + state -= 1 + return .none + + case .incrementButtonTapped: + state += 1 + return .none + + case .tick: + state += 1 + return .none + } +} +.lifecycle( + onAppear: { + Effect.timer(id: TimerId.self, every: 1, tolerance: 0, on: $0.mainQueue) + .map { _ in TimerAction.tick } + }, + onDisappear: { _ in + .cancel(id: TimerId.self) + } +) + +private struct TimerView: View { + let store: Store> + + var body: some View { + WithViewStore(self.store) { viewStore in + Section { + Text("Count: \(viewStore.state)") + .onAppear { viewStore.send(.onAppear) } + .onDisappear { viewStore.send(.onDisappear) } + + Button("Decrement") { viewStore.send(.action(.decrementButtonTapped)) } + + Button("Increment") { viewStore.send(.action(.incrementButtonTapped)) } + } + } + } +} + +struct Lifecycle_Previews: PreviewProvider { + static var previews: some View { + Group { + NavigationView { + LifecycleDemoView( + store: Store( + initialState: LifecycleDemoState(), + reducer: lifecycleDemoReducer, + environment: LifecycleDemoEnvironment( + mainQueue: .main + ) + ) + ) + } + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift new file mode 100644 index 00000000..95bf0799 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift @@ -0,0 +1,169 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how the `Reducer` struct can be extended to enhance reducers with extra \ + functionality. + + In it we introduce an interface for constructing reducers that need to be called recursively in \ + order to handle nested state and actions. It is handed itself as its first argument. + + Tap "Add row" to add a row to the current screen's list. Tap the left-hand side of a row to edit \ + its description, or tap the right-hand side of a row to navigate to its own associated list of \ + rows. + """ + +extension Reducer { + static func recurse( + _ reducer: @escaping (Self, inout State, Action, Environment) -> Effect + ) -> Self { + + var `self`: Self! + self = Self { state, action, environment in + reducer(self, &state, action, environment) + } + return self + } +} + +struct NestedState: Equatable, Identifiable { + var children: IdentifiedArrayOf = [] + let id: UUID + var description: String = "" +} + +indirect enum NestedAction: Equatable { + case append + case node(id: NestedState.ID, action: NestedAction) + case remove(IndexSet) + case rename(String) +} + +struct NestedEnvironment { + var uuid: () -> UUID +} + +let nestedReducer = Reducer< + NestedState, NestedAction, NestedEnvironment +>.recurse { `self`, state, action, environment in + switch action { + case .append: + state.children.append(NestedState(id: environment.uuid())) + return .none + + case .node: + return self.forEach( + state: \.children, + action: /NestedAction.node(id:action:), + environment: { $0 } + ) + .run(&state, action, environment) + + case let .remove(indexSet): + state.children.remove(atOffsets: indexSet) + return .none + + case let .rename(name): + state.description = name + return .none + } +} + +struct NestedView: View { + let store: Store + + var body: some View { + WithViewStore(self.store.scope(state: \.description)) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + ForEachStore( + self.store.scope(state: \.children, action: NestedAction.node(id:action:)) + ) { childStore in + WithViewStore(childStore) { childViewStore in + HStack { + TextField( + "Untitled", + text: childViewStore.binding(get: \.description, send: NestedAction.rename) + ) + + Spacer() + + NavigationLink( + destination: NestedView(store: childStore) + ) { + Text("") + } + } + } + } + .onDelete { viewStore.send(.remove($0)) } + } + .navigationBarTitle(viewStore.state.isEmpty ? "Untitled" : viewStore.state) + .navigationBarItems( + trailing: Button("Add row") { viewStore.send(.append) } + ) + } + } +} + +extension NestedState { + static let mock = NestedState( + children: [ + NestedState( + children: [ + NestedState( + children: [], + id: UUID(), + description: "" + ) + ], + id: UUID(), + description: "Bar" + ), + NestedState( + children: [ + NestedState( + children: [], + id: UUID(), + description: "Fizz" + ), + NestedState( + children: [], + id: UUID(), + description: "Buzz" + ), + ], + id: UUID(), + description: "Baz" + ), + NestedState( + children: [], + id: UUID(), + description: "" + ), + ], + id: UUID(), + description: "Foo" + ) +} + +#if DEBUG + struct NestedView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + NestedView( + store: Store( + initialState: .mock, + reducer: nestedReducer, + environment: NestedEnvironment( + uuid: UUID.init + ) + ) + ) + } + } + } +#endif diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift new file mode 100644 index 00000000..d9ec0624 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift @@ -0,0 +1,45 @@ +import Combine +import ComposableArchitecture +import Foundation + +struct DownloadClient { + var download: (URL) -> Effect + + struct Error: Swift.Error, Equatable {} + + enum Action: Equatable { + case response(Data) + case updateProgress(Double) + } +} + +extension DownloadClient { + static let live = DownloadClient( + download: { url in + .run { subscriber in + let task = URLSession.shared.dataTask(with: url) { data, _, error in + switch (data, error) { + case let (.some(data), _): + subscriber.send(.response(data)) + subscriber.send(completion: .finished) + case let (_, .some(error)): + subscriber.send(completion: .failure(Error())) + case (.none, .none): + fatalError("Data and Error should not both be nil") + } + } + + let observation = task.progress.observe(\.fractionCompleted) { progress, _ in + subscriber.send(.updateProgress(progress.fractionCompleted)) + } + + task.resume() + + return AnyCancellable { + observation.invalidate() + task.cancel() + } + } + } + ) +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift new file mode 100644 index 00000000..8ff65f19 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift @@ -0,0 +1,177 @@ +import ComposableArchitecture +import SwiftUI + +struct DownloadComponentState: Equatable { + var alert: AlertState? + let id: ID + var mode: Mode + let url: URL +} + +enum Mode: Equatable { + case downloaded + case downloading(progress: Double) + case notDownloaded + case startingToDownload + + var progress: Double { + if case let .downloading(progress) = self { return progress } + return 0 + } + + var isDownloading: Bool { + switch self { + case .downloaded, .notDownloaded: + return false + case .downloading, .startingToDownload: + return true + } + } +} + +enum DownloadComponentAction: Equatable { + case alert(AlertAction) + case buttonTapped + case downloadClient(Result) + + enum AlertAction: Equatable { + case deleteButtonTapped + case dismiss + case nevermindButtonTapped + case stopButtonTapped + } +} + +struct DownloadComponentEnvironment { + var downloadClient: DownloadClient + var mainQueue: AnySchedulerOf +} + +extension Reducer { + func downloadable( + state: WritableKeyPath>, + action: CasePath, + environment: @escaping (Environment) -> DownloadComponentEnvironment + ) -> Self { + .combine( + Reducer, DownloadComponentAction, DownloadComponentEnvironment> { + state, action, environment in + switch action { + case .alert(.deleteButtonTapped): + state.alert = nil + state.mode = .notDownloaded + return .none + + case .alert(.nevermindButtonTapped), + .alert(.dismiss): + state.alert = nil + return .none + + case .alert(.stopButtonTapped): + state.mode = .notDownloaded + state.alert = nil + return .cancel(id: state.id) + + case .buttonTapped: + switch state.mode { + case .downloaded: + state.alert = deleteAlert + return .none + + case .downloading: + state.alert = stopAlert + return .none + + case .notDownloaded: + state.mode = .startingToDownload + return environment.downloadClient + .download(state.url) + .throttle(for: 1, scheduler: environment.mainQueue, latest: true) + .catchToEffect(DownloadComponentAction.downloadClient) + .cancellable(id: state.id) + + case .startingToDownload: + state.alert = stopAlert + return .none + } + + case .downloadClient(.success(.response)): + state.mode = .downloaded + state.alert = nil + return .none + + case let .downloadClient(.success(.updateProgress(progress))): + state.mode = .downloading(progress: progress) + return .none + + case .downloadClient(.failure): + state.mode = .notDownloaded + state.alert = nil + return .none + } + } + .pullback(state: state, action: action, environment: environment), + self + ) + } +} + +private let deleteAlert = AlertState( + title: TextState("Do you want to delete this map from your offline storage?"), + primaryButton: .destructive(TextState("Delete"), action: .send(.deleteButtonTapped)), + secondaryButton: nevermindButton +) + +private let stopAlert = AlertState( + title: TextState("Do you want to stop downloading this map?"), + primaryButton: .destructive(TextState("Stop"), action: .send(.stopButtonTapped)), + secondaryButton: nevermindButton +) + +let nevermindButton = AlertState.Button + .cancel(TextState("Nevermind"), action: .send(.nevermindButtonTapped)) + +struct DownloadComponent: View { + let store: Store, DownloadComponentAction> + + var body: some View { + WithViewStore(self.store) { viewStore in + Button(action: { viewStore.send(.buttonTapped) }) { + if viewStore.mode == .downloaded { + Image(systemName: "checkmark.circle") + .accentColor(.blue) + } else if viewStore.mode.progress > 0 { + ZStack { + CircularProgressView(value: viewStore.mode.progress) + .frame(width: 16, height: 16) + + Rectangle() + .frame(width: 6, height: 6) + .foregroundColor(.black) + } + } else if viewStore.mode == .notDownloaded { + Image(systemName: "icloud.and.arrow.down") + .accentColor(.black) + } else if viewStore.mode == .startingToDownload { + ZStack { + ProgressView() + + Rectangle() + .frame(width: 6, height: 6) + .foregroundColor(.black) + } + } + } + .alert( + self.store.scope(state: \.alert, action: DownloadComponentAction.alert), + dismiss: .dismiss + ) + } + } +} + +struct DownloadComponent_Previews: PreviewProvider { + static var previews: some View { + DownloadList_Previews.previews + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift new file mode 100644 index 00000000..02fc8c15 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift @@ -0,0 +1,298 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how one can create reusable components in the Composable Architecture. + + The "download component" is a component that can be added to any view to enhance it with the \ + concept of downloading offline content. It facilitates downloading the data, displaying a \ + progress view while downloading, canceling an active download, and deleting previously \ + downloaded data. + + Tap the download icon to start a download, and tap again to cancel an in-flight download or to \ + remove a finished download. While a file is downloading you can tap a row to go to another \ + screen to see that the state is carried over. + """ + +struct CityMap: Equatable, Identifiable { + var blurb: String + var downloadVideoUrl: URL + let id: UUID + var title: String +} + +struct CityMapState: Equatable, Identifiable { + var downloadAlert: AlertState? + var downloadMode: Mode + var cityMap: CityMap + + var id: UUID { self.cityMap.id } + + var downloadComponent: DownloadComponentState { + get { + DownloadComponentState( + alert: self.downloadAlert, + id: self.cityMap.id, + mode: self.downloadMode, + url: self.cityMap.downloadVideoUrl + ) + } + set { + self.downloadAlert = newValue.alert + self.downloadMode = newValue.mode + } + } +} + +enum CityMapAction { + case downloadComponent(DownloadComponentAction) +} + +struct CityMapEnvironment { + var downloadClient: DownloadClient + var mainQueue: AnySchedulerOf +} + +let cityMapReducer = Reducer { + state, action, environment in + switch action { + case let .downloadComponent(.downloadClient(.success(.response(data)))): + // TODO: save to disk + return .none + + case .downloadComponent(.alert(.deleteButtonTapped)): + // TODO: delete file from disk + return .none + + case .downloadComponent: + return .none + } +} +.downloadable( + state: \.downloadComponent, + action: /CityMapAction.downloadComponent, + environment: { + DownloadComponentEnvironment( + downloadClient: $0.downloadClient, + mainQueue: $0.mainQueue + ) + } +) + +struct CityMapRowView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + HStack { + NavigationLink( + destination: CityMapDetailView(store: self.store) + ) { + HStack { + Image(systemName: "map") + Text(viewStore.cityMap.title) + } + .layoutPriority(1) + + Spacer() + + DownloadComponent( + store: self.store.scope( + state: \.downloadComponent, + action: CityMapAction.downloadComponent + ) + ) + .padding(.trailing, 8) + } + } + } + } +} + +struct CityMapDetailView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + VStack(spacing: 32) { + Text(viewStore.cityMap.blurb) + + HStack { + if viewStore.downloadMode == .notDownloaded { + Text("Download for offline viewing") + } else if viewStore.downloadMode == .downloaded { + Text("Downloaded") + } else { + Text("Downloading \(Int(100 * viewStore.downloadComponent.mode.progress))%") + } + + Spacer() + + DownloadComponent( + store: self.store.scope( + state: \.downloadComponent, + action: CityMapAction.downloadComponent + ) + ) + } + + Spacer() + } + .navigationBarTitle(viewStore.cityMap.title) + .padding() + } + } +} + +struct MapAppState { + var cityMaps: IdentifiedArrayOf +} + +enum MapAppAction { + case cityMaps(id: CityMapState.ID, action: CityMapAction) +} + +struct MapAppEnvironment { + var downloadClient: DownloadClient + var mainQueue: AnySchedulerOf +} + +let mapAppReducer: Reducer = cityMapReducer.forEach( + state: \MapAppState.cityMaps, + action: /MapAppAction.cityMaps(id:action:), + environment: { + CityMapEnvironment( + downloadClient: $0.downloadClient, + mainQueue: $0.mainQueue + ) + } +) + +struct CitiesView: View { + let store: Store + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + ForEachStore( + self.store.scope(state: \.cityMaps, action: MapAppAction.cityMaps(id:action:)) + ) { cityMapStore in + CityMapRowView(store: cityMapStore) + .buttonStyle(.borderless) + } + } + .navigationBarTitle("Offline Downloads") + } +} + +struct DownloadList_Previews: PreviewProvider { + static var previews: some View { + Group { + NavigationView { + CitiesView( + store: Store( + initialState: MapAppState(cityMaps: .mocks), + reducer: mapAppReducer, + environment: MapAppEnvironment( + downloadClient: .live, + mainQueue: .main + ) + ) + ) + } + + NavigationView { + CityMapDetailView( + store: Store( + initialState: IdentifiedArray.mocks.first!, + reducer: .empty, + environment: () + ) + ) + } + } + } +} + +extension IdentifiedArray where ID == CityMapState.ID, Element == CityMapState { + static let mocks: Self = [ + CityMapState( + downloadMode: .notDownloaded, + cityMap: CityMap( + blurb: """ + New York City (NYC), known colloquially as New York (NY) and officially as the City of \ + New York, is the most populous city in the United States. With an estimated 2018 \ + population of 8,398,748 distributed over about 302.6 square miles (784 km2), New York \ + is also the most densely populated major city in the United States. + """, + downloadVideoUrl: URL(string: "http://ipv4.download.thinkbroadband.com/50MB.zip")!, + id: UUID(), + title: "New York, NY" + ) + ), + CityMapState( + downloadMode: .notDownloaded, + cityMap: CityMap( + blurb: """ + Los Angeles, officially the City of Los Angeles and often known by its initials L.A., \ + is the largest city in the U.S. state of California. With an estimated population of \ + nearly four million people, it is the country's second most populous city (after New \ + York City) and the third most populous city in North America (after Mexico City and \ + New York City). Los Angeles is known for its Mediterranean climate, ethnic diversity, \ + Hollywood entertainment industry, and its sprawling metropolis. + """, + downloadVideoUrl: URL(string: "http://ipv4.download.thinkbroadband.com/50MB.zip")!, + id: UUID(), + title: "Los Angeles, LA" + ) + ), + CityMapState( + downloadMode: .notDownloaded, + cityMap: CityMap( + blurb: """ + Paris is the capital and most populous city of France, with a population of 2,148,271 \ + residents (official estimate, 1 January 2020) in an area of 105 square kilometres (41 \ + square miles). Since the 17th century, Paris has been one of Europe's major centres of \ + finance, diplomacy, commerce, fashion, science and arts. + """, + downloadVideoUrl: URL(string: "http://ipv4.download.thinkbroadband.com/50MB.zip")!, + id: UUID(), + title: "Paris, France" + ) + ), + CityMapState( + downloadMode: .notDownloaded, + cityMap: CityMap( + blurb: """ + Tokyo, officially Tokyo Metropolis (東京都, Tōkyō-to), is the capital of Japan and the \ + most populous of the country's 47 prefectures. Located at the head of Tokyo Bay, the \ + prefecture forms part of the Kantō region on the central Pacific coast of Japan's main \ + island, Honshu. Tokyo is the political, economic, and cultural center of Japan, and \ + houses the seat of the Emperor and the national government. + """, + downloadVideoUrl: URL(string: "http://ipv4.download.thinkbroadband.com/50MB.zip")!, + id: UUID(), + title: "Tokyo, Japan" + ) + ), + CityMapState( + downloadMode: .notDownloaded, + cityMap: CityMap( + blurb: """ + Buenos Aires is the capital and largest city of Argentina. The city is located on the \ + western shore of the estuary of the Río de la Plata, on the South American continent's \ + southeastern coast. "Buenos Aires" can be translated as "fair winds" or "good airs", \ + but the former was the meaning intended by the founders in the 16th century, by the \ + use of the original name "Real de Nuestra Señora Santa María del Buen Ayre", named \ + after the Madonna of Bonaria in Sardinia. + """, + downloadVideoUrl: URL(string: "http://ipv4.download.thinkbroadband.com/50MB.zip")!, + id: UUID(), + title: "Buenos Aires, Argentina" + ) + ), + ] +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift new file mode 100644 index 00000000..248e1f8e --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift @@ -0,0 +1,238 @@ +import Combine +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how one can create reusable components in the Composable Architecture. + + It introduces the domain, logic, and view around "favoriting" something, which is considerably \ + complex. + + A feature can give itself the ability to "favorite" part of its state by embedding the domain of \ + favoriting, using the `favorite` higher-order reducer, and passing an appropriately scoped store \ + to `FavoriteButton`. + + Tapping the favorite button on a row will instantly reflect in the UI and fire off an effect to \ + do any necessary work, like writing to a database or making an API request. We have simulated a \ + request that takes 1 second to run and may fail 25% of the time. Failures result in rolling back \ + favorite state and rendering an alert. + """ + +// MARK: - Favorite domain + +struct FavoriteState: Equatable, Identifiable { + var alert: AlertState? + let id: ID + var isFavorite: Bool +} + +enum FavoriteAction: Equatable { + case alertDismissed + case buttonTapped + case response(Result) +} + +struct FavoriteEnvironment { + var request: (ID, Bool) -> Effect + var mainQueue: AnySchedulerOf +} + +/// A cancellation token that cancels in-flight favoriting requests. +struct FavoriteCancelId: Hashable { + var id: ID +} + +/// A wrapper for errors that occur when favoriting. +struct FavoriteError: Equatable, Error, Identifiable { + let error: NSError + var localizedDescription: String { self.error.localizedDescription } + var id: String { self.error.localizedDescription } + static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } +} + +extension Reducer { + /// Enhances a reducer with favoriting logic. + func favorite( + state: WritableKeyPath>, + action: CasePath, + environment: @escaping (Environment) -> FavoriteEnvironment + ) -> Self { + .combine( + self, + Reducer, FavoriteAction, FavoriteEnvironment> { + state, action, environment in + switch action { + case .alertDismissed: + state.alert = nil + state.isFavorite.toggle() + return .none + + case .buttonTapped: + state.isFavorite.toggle() + + return environment.request(state.id, state.isFavorite) + .receive(on: environment.mainQueue) + .mapError { FavoriteError(error: $0 as NSError) } + .catchToEffect(FavoriteAction.response) + .cancellable(id: FavoriteCancelId(id: state.id), cancelInFlight: true) + + case let .response(.failure(error)): + state.alert = AlertState(title: TextState(error.localizedDescription)) + return .none + + case let .response(.success(isFavorite)): + state.isFavorite = isFavorite + return .none + } + } + .pullback(state: state, action: action, environment: environment) + ) + } +} + +struct FavoriteButton: View { + let store: Store, FavoriteAction> + + var body: some View { + WithViewStore(self.store) { viewStore in + Button(action: { viewStore.send(.buttonTapped) }) { + Image(systemName: viewStore.isFavorite ? "heart.fill" : "heart") + } + .alert(self.store.scope(state: \.alert), dismiss: .alertDismissed) + } + } +} + +// MARK: Feature domain - + +struct EpisodeState: Equatable, Identifiable { + var alert: AlertState? + let id: UUID + var isFavorite: Bool + let title: String + + var favorite: FavoriteState { + get { .init(alert: self.alert, id: self.id, isFavorite: self.isFavorite) } + set { (self.alert, self.isFavorite) = (newValue.alert, newValue.isFavorite) } + } +} + +enum EpisodeAction: Equatable { + case favorite(FavoriteAction) +} + +struct EpisodeEnvironment { + var favorite: (EpisodeState.ID, Bool) -> Effect + var mainQueue: AnySchedulerOf +} + +struct EpisodeView: View { + let store: Store + + var body: some View { + WithViewStore(self.store) { viewStore in + HStack(alignment: .firstTextBaseline) { + Text(viewStore.title) + + Spacer() + + FavoriteButton( + store: self.store.scope(state: \.favorite, action: EpisodeAction.favorite)) + } + } + } +} + +let episodeReducer = Reducer.empty.favorite( + state: \.favorite, + action: /EpisodeAction.favorite, + environment: { FavoriteEnvironment(request: $0.favorite, mainQueue: $0.mainQueue) } +) + +struct EpisodesState: Equatable { + var episodes: IdentifiedArrayOf = [] +} + +enum EpisodesAction: Equatable { + case episode(id: EpisodeState.ID, action: EpisodeAction) +} + +struct EpisodesEnvironment { + var favorite: (UUID, Bool) -> Effect + var mainQueue: AnySchedulerOf +} + +let episodesReducer: Reducer = + episodeReducer.forEach( + state: \EpisodesState.episodes, + action: /EpisodesAction.episode(id:action:), + environment: { EpisodeEnvironment(favorite: $0.favorite, mainQueue: $0.mainQueue) } + ) + +struct EpisodesView: View { + let store: Store + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + ForEachStore( + self.store.scope(state: \.episodes, action: EpisodesAction.episode(id:action:)) + ) { rowStore in + EpisodeView(store: rowStore) + .buttonStyle(.borderless) + } + } + .navigationBarTitle("Favoriting") + } +} + +struct EpisodesView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EpisodesView( + store: Store( + initialState: EpisodesState( + episodes: .mocks + ), + reducer: episodesReducer, + environment: EpisodesEnvironment( + favorite: favorite(id:isFavorite:), + mainQueue: .main + ) + ) + ) + } + } +} + +func favorite(id: ID, isFavorite: Bool) -> Effect { + Effect.future { callback in + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + if .random(in: 0...1) > 0.25 { + callback(.success(isFavorite)) + } else { + callback( + .failure( + NSError( + domain: "co.pointfree", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Something went wrong!"] + ) + ) + ) + } + } + } +} + +extension IdentifiedArray where ID == EpisodeState.ID, Element == EpisodeState { + static let mocks: Self = [ + EpisodeState(id: UUID(), isFavorite: false, title: "Functions"), + EpisodeState(id: UUID(), isFavorite: false, title: "Side Effects"), + EpisodeState(id: UUID(), isFavorite: false, title: "Algebraic Data Types"), + EpisodeState(id: UUID(), isFavorite: false, title: "DSLs"), + EpisodeState(id: UUID(), isFavorite: false, title: "Parsers"), + EpisodeState(id: UUID(), isFavorite: false, title: "Composable Architecture"), + ] +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png new file mode 100644 index 00000000..b1516e5a Binary files /dev/null and b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png differ diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png new file mode 100644 index 00000000..869cd81f Binary files /dev/null and b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png differ diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-iPadPro@2x.png b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-iPadPro@2x.png new file mode 100644 index 00000000..94a32fd6 Binary files /dev/null and b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-iPadPro@2x.png differ diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 00000000..6f108dda Binary files /dev/null and b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..4f45077f --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,103 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "AppIcon-60@2x.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "AppIcon.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "AppIcon-76@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "AppIcon-iPadPro@2x.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "transparent.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/transparent.png b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/transparent.png new file mode 100644 index 00000000..bae1e0d7 Binary files /dev/null and b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/transparent.png differ diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift new file mode 100644 index 00000000..579b06bc --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift @@ -0,0 +1,17 @@ +import ComposableArchitecture +import SwiftUI + +@main +struct CaseStudiesApp: App { + var body: some Scene { + WindowGroup { + RootView( + store: Store( + initialState: RootState(), + reducer: rootReducer, + environment: .live + ) + ) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift new file mode 100644 index 00000000..3964c569 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift @@ -0,0 +1,45 @@ +import Combine +import ComposableArchitecture +import XCTestDynamicOverlay + +struct FactClient { + var fetch: (Int) -> Effect + var fetchAsync: @Sendable (Int) async throws -> String + + struct Failure: Error, Equatable {} +} + +// This is the "live" fact dependency that reaches into the outside world to fetch trivia. +// Typically this live implementation of the dependency would live in its own module so that the +// main feature doesn't need to compile it. +extension FactClient { + static let live = Self( + fetch: { number in + Effect.task { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + let (data, _) = try await URLSession.shared + .data(from: URL(string: "http://numbersapi.com/\(number)/trivia")!) + return String(decoding: data, as: UTF8.self) + } + .mapError { _ in Failure() } + .eraseToEffect() + }, + fetchAsync: { number in + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + let (data, _) = try await URLSession.shared + .data(from: URL(string: "http://numbersapi.com/\(number)/trivia")!) + return String(decoding: data, as: UTF8.self) + } + ) +} + +#if DEBUG + extension FactClient { + // This is the "unimplemented" fact dependency that is useful to plug into tests that you want + // to prove do not need the dependency. + static let unimplemented = Self( + fetch: { _ in .unimplemented("\(Self.self).fetch") }, + fetchAsync: XCTUnimplemented("\(Self.self).fetchAsync") + ) + } +#endif diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Info.plist b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Info.plist new file mode 100644 index 00000000..b94c796a --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Info.plist @@ -0,0 +1,48 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/AboutView.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/AboutView.swift new file mode 100644 index 00000000..6ca62459 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/AboutView.swift @@ -0,0 +1,11 @@ +import SwiftUI + +struct AboutView: View { + let readMe: String + + var body: some View { + DisclosureGroup("About this case study") { + Text(template: self.readMe) + } + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift new file mode 100644 index 00000000..cc59a23f --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct CircularProgressView: View { + private let value: Double + + init(value: Double) { + self.value = value + } + + var body: some View { + Circle() + .trim(from: 0, to: CGFloat(self.value)) + .stroke(style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .foregroundColor(.black) + .rotationEffect(.degrees(-90)) + .animation(.easeIn, value: self.value) + } +} + +struct CircularProgressView_Previews: PreviewProvider { + static var previews: some View { + CircularProgressView(value: 0.3).frame(width: 44, height: 44) + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/ResignFirstResponder.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/ResignFirstResponder.swift new file mode 100644 index 00000000..6c69cecc --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/ResignFirstResponder.swift @@ -0,0 +1,20 @@ +import SwiftUI + +extension Binding { + /// SwiftUI will print errors to the console about "AttributeGraph: cycle detected" if you disable + /// a text field while it is focused. This hack will force all fields to unfocus before we write + /// to a binding that may disable the fields. + /// + /// See also: https://stackoverflow.com/a/69653555 + func resignFirstResponder() -> Self { + Self( + get: { self.wrappedValue }, + set: { newValue, transaction in + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil + ) + self.transaction(transaction).wrappedValue = newValue + } + ) + } +} diff --git a/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/TemplateText.swift b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/TemplateText.swift new file mode 100644 index 00000000..e5fb1ca1 --- /dev/null +++ b/0195-tca-concurrency-pt1/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/TemplateText.swift @@ -0,0 +1,59 @@ +import SwiftUI + +extension Text { + init(template: String, _ style: Font.TextStyle = .body) { + enum Style: Hashable { + case code + case emphasis + case strong + } + + var segments: [Text] = [] + var currentValue = "" + var currentStyles: Set