diff --git a/0261-observable-architecture-pt3/README.md b/0261-observable-architecture-pt3/README.md index 7707408e..4a7036e3 100644 --- a/0261-observable-architecture-pt3/README.md +++ b/0261-observable-architecture-pt3/README.md @@ -1,5 +1,5 @@ ## [Point-Free](https://www.pointfree.co) -> #### This directory contains code from Point-Free Episode: [Observable Architecture: Observing Optionals](https://www.pointfree.co/episodes/ep260-observable-architecture-observing-optionals) +> #### This directory contains code from Point-Free Episode: [Observable Architecture: Observing Optionals](https://www.pointfree.co/episodes/ep261-observable-architecture-observing-optionals) > > The Composable Architecture can now observe struct state, but it requires a lot of boilerplate. Let’s fix this by leveraging the `@Observable` macro from the Swift open source repository. And let’s explore what it means to observe optional state and eliminate the library’s `IfLetStore` view for a simple `if let` statement. diff --git a/0262-observable-architecture-pt4/README.md b/0262-observable-architecture-pt4/README.md new file mode 100644 index 00000000..29b433f7 --- /dev/null +++ b/0262-observable-architecture-pt4/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [Observable Architecture: Observing Enums](https://www.pointfree.co/episodes/ep262-observable-architecture-observing-enums) +> +> We’ve made structs and optionals observable in the Composable Architecture, eliminating the need for `ViewStore`s and `IfLetStore`s, so what about enums? If we can make enums observable, we could further eliminate the concept of the `SwitchStore`, greatly improving the ergonomics of working with enums in the library. diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/.github/CODE_OF_CONDUCT.md b/0262-observable-architecture-pt4/swift-composable-architecture/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..6f9886f0 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# 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, caste, color, 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 +. +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.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/.github/ISSUE_TEMPLATE/bug_report.yml b/0262-observable-architecture-pt4/swift-composable-architecture/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..c1e65d9d --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,73 @@ +name: Bug Report +description: Something isn't working as expected +labels: [bug] +body: +- type: markdown + attributes: + value: | + Thank you for contributing to the Composable Architecture! + + Before you submit your issue, please complete each text area below with the relevant details for your bug, and complete the steps in the checklist +- type: textarea + attributes: + label: Description + description: | + A short description of the incorrect behavior. + + If you think this issue has been recently introduced and did not occur in an earlier version, please note that. If possible, include the last version that the behavior was correct in addition to your current version. + validations: + required: true +- type: checkboxes + attributes: + label: Checklist + options: + - label: I have determined whether this bug is also reproducible in a vanilla SwiftUI project. + required: false + - label: If possible, I've reproduced the issue using the `main` branch of this package. + required: false + - label: This issue hasn't been addressed in an [existing GitHub issue](https://github.com/pointfreeco/swift-composable-architecture/issues) or [discussion](https://github.com/pointfreeco/swift-composable-architecture/discussions). + required: true +- type: textarea + attributes: + label: Expected behavior + description: Describe what you expected to happen. + validations: + required: false +- type: textarea + attributes: + label: Actual behavior + description: Describe or copy/paste the behavior you observe. + validations: + required: false +- type: textarea + attributes: + label: Steps to reproduce + description: | + Explanation of how to reproduce the incorrect behavior. + + This could include an attached project or link to code that is exhibiting the issue, and/or a screen recording. + placeholder: | + 1. ... + validations: + required: false +- type: input + attributes: + label: The Composable Architecture version information + description: The version of the Composable Architecture used to reproduce this issue. + placeholder: "'0.38.0' for example, or a commit hash" +- type: input + attributes: + label: Destination operating system + description: The OS running your TCA application. + placeholder: "'iOS 15' for example" +- type: input + attributes: + label: Xcode version information + description: The version of Xcode used to reproduce this issue. + placeholder: "The version displayed from 'Xcode 〉About Xcode'" +- type: textarea + attributes: + label: Swift Compiler version information + description: The version of Swift used to reproduce this issue. + placeholder: Output from 'xcrun swiftc --version' + render: shell diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/.github/ISSUE_TEMPLATE/config.yml b/0262-observable-architecture-pt4/swift-composable-architecture/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..33bd9665 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,15 @@ +blank_issues_enabled: false + +contact_links: + - name: Project Discussion + url: https://github.com/pointfreeco/swift-composable-architecture/discussions + about: Composable Architecture Q&A, ideas, and more + - name: Documentation + url: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/ + about: Read the Composable Architecture's documentation + - name: Videos + url: https://www.pointfree.co/collections/composable-architecture + about: Watch videos to get a behind-the-scenes look at how the Composable Architecture was motivated and built + - name: Slack + url: https://www.pointfree.co/slack-invite + about: Community chat diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/.github/package.xcworkspace/contents.xcworkspacedata b/0262-observable-architecture-pt4/swift-composable-architecture/.github/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..0fd0bc31 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.github/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0262-observable-architecture-pt4/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme b/0262-observable-architecture-pt4/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme new file mode 100644 index 00000000..318b7853 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme b/0262-observable-architecture-pt4/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme new file mode 100644 index 00000000..9bcb82da --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/ci.yml b/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/ci.yml new file mode 100644 index 00000000..58f1e68f --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + library-swift-latest: + name: Library + runs-on: macos-13 + strategy: + matrix: + config: + - debug + - release + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 15 + run: sudo xcode-select -s /Applications/Xcode_15.0.1.app + - name: Run ${{ matrix.config }} tests + run: make CONFIG=${{ matrix.config }} test-library + + library-evolution: + name: Library (evolution) + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 15 + run: sudo xcode-select -s /Applications/Xcode_15.0.1.app + - name: Build for library evolution + run: make build-for-library-evolution + + benchmarks: + name: Benchmarks + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 15 + run: sudo xcode-select -s /Applications/Xcode_15.0.1.app + - name: Run benchmark + run: make benchmark + + examples: + name: Examples + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 15 + run: sudo xcode-select -s /Applications/Xcode_15.0.1.app + - name: Run tests + run: make test-examples diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/documentation.yml b/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/documentation.yml new file mode 100644 index 00000000..e77c5e8b --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/documentation.yml @@ -0,0 +1,74 @@ +# 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: + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: macos-13 + steps: + - name: Select Xcode 15.0.1 + run: sudo xcode-select -s /Applications/Xcode_15.0.1.app + + - name: Checkout Package + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Checkout gh-pages Branch + uses: actions/checkout@v4 + with: + ref: gh-pages + path: docs-out + + - name: Build documentation + run: > + rm -rf docs-out/.git; + rm -rf docs-out/main; + git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | tail -n +6 | xargs -I {} rm -rf {}; + + for tag in $(echo "main"; git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | head -6); + do + if [ -d "docs-out/$tag/data/documentation/composablearchitecture" ] + then + echo "✅ Documentation for "$tag" already exists."; + else + echo "⏳ Generating documentation for ComposableArchitecture @ "$tag" release."; + rm -rf "docs-out/$tag"; + + git checkout .; + git checkout "$tag"; + + swift package \ + --allow-writing-to-directory docs-out/"$tag" \ + generate-documentation \ + --target ComposableArchitecture \ + --output-path docs-out/"$tag" \ + --transform-for-static-hosting \ + --hosting-base-path /swift-composable-architecture/"$tag" \ + && echo "✅ Documentation generated for ComposableArchitecture @ "$tag" release." \ + || echo "⚠️ Documentation skipped for ComposableArchitecture @ "$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/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/format.yml b/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/format.yml new file mode 100644 index 00000000..c04d1353 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/format.yml @@ -0,0 +1,29 @@ +name: Format + +on: + push: + branches: + - main + +concurrency: + group: format-${{ github.ref }} + cancel-in-progress: true + +jobs: + swift_format: + name: swift-format + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 14.3 + run: sudo xcode-select -s /Applications/Xcode_14.3.app + - name: Install swift-format + run: brew install swift-format + - 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/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/release.yml b/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/release.yml new file mode 100644 index 00000000..23a4e949 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: Release +on: + release: + types: [published] + workflow_dispatch: +jobs: + project-channel: + runs-on: ubuntu-latest + steps: + - name: Dump Github context + env: + GITHUB_CONTEXT: ${{ toJSON(github) }} + run: echo "$GITHUB_CONTEXT" + - name: Slack Notification on SUCCESS + if: success() + uses: tokorom/action-slack-incoming-webhook@main + env: + INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_PROJECT_CHANNEL_WEBHOOK_URL }} + with: + text: swift-composable-architecture ${{ github.event.release.tag_name }} has been released. + blocks: | + [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "swift-composable-architecture ${{ github.event.release.tag_name}}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ${{ toJSON(github.event.release.body) }} + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "${{ github.event.release.html_url }}" + } + } + ] + + releases-channel: + runs-on: ubuntu-latest + steps: + - name: Dump Github context + env: + GITHUB_CONTEXT: ${{ toJSON(github) }} + run: echo "$GITHUB_CONTEXT" + - name: Slack Notification on SUCCESS + if: success() + uses: tokorom/action-slack-incoming-webhook@main + env: + INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }} + with: + text: swift-composable-architecture ${{ github.event.release.tag_name }} has been released. + blocks: | + [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "swift-composable-architecture ${{ github.event.release.tag_name}}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ${{ toJSON(github.event.release.body) }} + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "${{ github.event.release.html_url }}" + } + } + ] diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/scheduled-ci.yml b/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/scheduled-ci.yml new file mode 100644 index 00000000..153aacc3 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.github/workflows/scheduled-ci.yml @@ -0,0 +1,15 @@ +on: + schedule: + - cron: '30 8 * * *' + - cron: '30 20 * * *' + +jobs: + integration: + name: Integration + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 15 + run: sudo xcode-select -s /Applications/Xcode_15.0.1.app + - name: Run tests + run: make test-integration diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/.gitignore b/0262-observable-architecture-pt4/swift-composable-architecture/.gitignore new file mode 100644 index 00000000..ac4e556f --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/.build +/.swiftpm +/Packages +/*.swiftinterface +/*.xcodeproj +xcuserdata/ diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/.spi.yml b/0262-observable-architecture-pt4/swift-composable-architecture/.spi.yml new file mode 100644 index 00000000..e4bbf116 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/.spi.yml @@ -0,0 +1,13 @@ +version: 1 +builder: + configs: + - platform: ios + scheme: ComposableArchitecture + - platform: macos-xcodebuild + scheme: ComposableArchitecture + - platform: tvos + scheme: ComposableArchitecture + - platform: watchos + scheme: ComposableArchitecture + - documentation_targets: [ComposableArchitecture] + swift_version: 5.9 diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/contents.xcworkspacedata b/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..f562e7d0 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..08de0be8 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme b/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme new file mode 100644 index 00000000..417eb423 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme b/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme new file mode 100644 index 00000000..2ef03e92 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj new file mode 100644 index 00000000..e27c60d2 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj @@ -0,0 +1,1260 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 433B8B762A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433B8B752A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.swift */; }; + 4F5AC11F24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5AC11E24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.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 */; }; + 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 */; }; + 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 */; }; + CA78F0CD28DA47D70026C4AD /* 04-HigherOrderReducers-RecursionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA78F0CC28DA47D70026C4AD /* 04-HigherOrderReducers-RecursionTests.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 */; }; + 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 /* FocusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E8724B8E26E00539345 /* FocusTests.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 */; }; + DC3C87B029A48C4D004D9104 /* 03-NavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3C87AF29A48C4D004D9104 /* 03-NavigationStack.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 */; }; + DC89C44D244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC89C44C244621A5006900B9 /* 03-Navigation-NavigateAndLoad.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 */; }; + 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 */ + 433B8B752A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Multiple-Destinations.swift"; sourceTree = ""; }; + 4F5AC11E24ECC7E4009DC50B /* 01-GettingStarted-SharedStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-SharedStateTests.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 = ""; }; + 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 = ""; }; + 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 = ""; }; + CA78F0CC28DA47D70026C4AD /* 04-HigherOrderReducers-RecursionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-RecursionTests.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 = ""; }; + 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 /* FocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusTests.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 = ""; }; + DC3C87AF29A48C4D004D9104 /* 03-NavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-NavigationStack.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 = ""; }; + DC89C44C244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-NavigateAndLoad.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 = ""; }; + 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 /* FocusTests.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 */, + 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 */, + DC89C45224465451006900B9 /* 03-Navigation-Lists-NavigateAndLoad.swift */, + DC89C44C244621A5006900B9 /* 03-Navigation-NavigateAndLoad.swift */, + DC072321244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift */, + DCC68EAA244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift */, + 433B8B752A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.swift */, + DC3C87AF29A48C4D004D9104 /* 03-NavigationStack.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 */, + CA78F0CC28DA47D70026C4AD /* 04-HigherOrderReducers-RecursionTests.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 = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 1150; + LastUpgradeCheck = 1500; + 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 /* FocusTests.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 = ( + DC3C87B029A48C4D004D9104 /* 03-NavigationStack.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 */, + DCC68EE32447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift in Sources */, + CA3E421F26B8337500581ABC /* 01-GettingStarted-FocusState.swift in Sources */, + DCC68EDF2447BC810037F998 /* TemplateText.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 */, + 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 */, + 433B8B762A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.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 */, + CAA9ADCA2446605B0003A984 /* 02-Effects-LongLiving.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 */, + DC07231724465D1E003A8B65 /* 02-Effects-TimersTests.swift in Sources */, + CA50BE6024A8F46500FE7DBA /* 01-GettingStarted-AlertsAndConfirmationDialogsTests.swift in Sources */, + CAA9ADC424465AB00003A984 /* 02-Effects-BasicsTests.swift in Sources */, + CA78F0CD28DA47D70026C4AD /* 04-HigherOrderReducers-RecursionTests.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; + ENABLE_USER_SCRIPT_SANDBOXING = 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; + ENABLE_USER_SCRIPT_SANDBOXING = 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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme new file mode 100644 index 00000000..557fddd0 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme new file mode 100644 index 00000000..a32178dd --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/tvOSCaseStudies.xcscheme b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/tvOSCaseStudies.xcscheme new file mode 100644 index 00000000..517604ec --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/tvOSCaseStudies.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/README.md b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/README.md new file mode 100644 index 00000000..0468b9f7 --- /dev/null +++ b/0262-observable-architecture-pt4/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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift new file mode 100644 index 00000000..d17e63f8 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift @@ -0,0 +1,119 @@ +import ComposableArchitecture +import SwiftUI + +struct RootView: View { + @State var isNavigationStackCaseStudyPresented = false + + var body: some View { + NavigationStack { + Form { + Section { + NavigationLink("Basics") { + CounterDemoView() + } + NavigationLink("Combining reducers") { + TwoCountersView() + } + NavigationLink("Bindings") { + BindingBasicsView() + } + NavigationLink("Form bindings") { + BindingFormView() + } + NavigationLink("Optional state") { + OptionalBasicsView() + } + NavigationLink("Shared state") { + SharedStateView() + } + NavigationLink("Alerts and Confirmation Dialogs") { + AlertAndConfirmationDialogView() + } + NavigationLink("Focus State") { + FocusDemoView() + } + NavigationLink("Animations") { + AnimationsView() + } + } header: { + Text("Getting started") + } + + Section { + NavigationLink("Basics") { + EffectsBasicsView() + } + NavigationLink("Cancellation") { + EffectsCancellationView() + } + NavigationLink("Long-living effects") { + LongLivingEffectsView() + } + NavigationLink("Refreshable") { + RefreshableView() + } + NavigationLink("Timers") { + TimersView() + } + NavigationLink("Web socket") { + WebSocketView() + } + } header: { + Text("Effects") + } + + Section { + Button("Stack") { + self.isNavigationStackCaseStudyPresented = true + } + .buttonStyle(.plain) + + NavigationLink("Navigate and load data") { + NavigateAndLoadView() + } + + NavigationLink("Lists: Navigate and load data") { + NavigateAndLoadListView() + } + NavigationLink("Sheets: Present and load data") { + PresentAndLoadView() + } + NavigationLink("Sheets: Load data then present") { + LoadThenPresentView() + } + NavigationLink("Multiple destinations") { + MultipleDestinationsView() + } + } header: { + Text("Navigation") + } + + Section { + NavigationLink("Reusable favoriting component") { + EpisodesView() + } + NavigationLink("Reusable offline download component") { + CitiesView() + } + NavigationLink("Recursive state and actions") { + NestedView() + } + } header: { + Text("Higher-order reducers") + } + } + .navigationTitle("Case Studies") + .sheet(isPresented: self.$isNavigationStackCaseStudyPresented) { + NavigationDemoView() + } + } + } +} + +// MARK: - SwiftUI previews + +struct RootView_Previews: PreviewProvider { + static var previews: some View { + RootView() + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift new file mode 100644 index 00000000..58994fb6 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift @@ -0,0 +1,147 @@ +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 + """ + +// MARK: - Feature domain + +@Reducer +struct AlertAndConfirmationDialog { + struct State: Equatable { + @PresentationState var alert: AlertState? + @PresentationState var confirmationDialog: ConfirmationDialogState? + var count = 0 + } + + enum Action { + case alert(PresentationAction) + case alertButtonTapped + case confirmationDialog(PresentationAction) + case confirmationDialogButtonTapped + + enum Alert { + case incrementButtonTapped + } + enum ConfirmationDialog { + case incrementButtonTapped + case decrementButtonTapped + } + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .alert(.presented(.incrementButtonTapped)), + .confirmationDialog(.presented(.incrementButtonTapped)): + state.alert = AlertState { TextState("Incremented!") } + state.count += 1 + return .none + + case .alert: + return .none + + case .alertButtonTapped: + state.alert = AlertState { + TextState("Alert!") + } actions: { + ButtonState(role: .cancel) { + TextState("Cancel") + } + ButtonState(action: .incrementButtonTapped) { + TextState("Increment") + } + } message: { + TextState("This is an alert") + } + return .none + + case .confirmationDialog(.presented(.decrementButtonTapped)): + state.alert = AlertState { TextState("Decremented!") } + state.count -= 1 + return .none + + case .confirmationDialog: + return .none + + case .confirmationDialogButtonTapped: + state.confirmationDialog = ConfirmationDialogState { + TextState("Confirmation dialog") + } actions: { + ButtonState(role: .cancel) { + TextState("Cancel") + } + ButtonState(action: .incrementButtonTapped) { + TextState("Increment") + } + ButtonState(action: .decrementButtonTapped) { + TextState("Decrement") + } + } message: { + TextState("This is a confirmation dialog.") + } + return .none + } + } + .ifLet(\.$alert, action: \.alert) + .ifLet(\.$confirmationDialog, action: \.confirmationDialog) + } +} + +// MARK: - Feature view + +struct AlertAndConfirmationDialogView: View { + @State var store = Store(initialState: AlertAndConfirmationDialog.State()) { + AlertAndConfirmationDialog() + } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + Text("Count: \(viewStore.count)") + Button("Alert") { viewStore.send(.alertButtonTapped) } + Button("Confirmation Dialog") { viewStore.send(.confirmationDialogButtonTapped) } + } + } + .navigationTitle("Alerts & Dialogs") + .alert( + store: self.store.scope(state: \.$alert, action: \.alert) + ) + .confirmationDialog( + store: self.store.scope(state: \.$confirmationDialog, action: \.confirmationDialog) + ) + } +} + +// MARK: - SwiftUI previews + +struct AlertAndConfirmationDialog_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + AlertAndConfirmationDialogView( + store: Store(initialState: AlertAndConfirmationDialog.State()) { + AlertAndConfirmationDialog() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift new file mode 100644 index 00000000..57b1fab3 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift @@ -0,0 +1,179 @@ +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 `Effect.run` style of effects \ + which allows you to send actions with animations. + + 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. + """ + +// MARK: - Feature domain + +@Reducer +struct Animations { + struct State: Equatable { + @PresentationState var alert: AlertState? + var circleCenter: CGPoint? + var circleColor = Color.black + var isCircleScaled = false + } + + enum Action: Sendable { + case alert(PresentationAction) + case circleScaleToggleChanged(Bool) + case rainbowButtonTapped + case resetButtonTapped + case setColor(Color) + case tapped(CGPoint) + + enum Alert: Sendable { + case resetConfirmationButtonTapped + } + } + + @Dependency(\.continuousClock) var clock + + private enum CancelID { case rainbow } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .alert(.presented(.resetConfirmationButtonTapped)): + state = State() + return .cancel(id: CancelID.rainbow) + + case .alert: + return .none + + case let .circleScaleToggleChanged(isScaled): + state.isCircleScaled = isScaled + return .none + + case .rainbowButtonTapped: + return .run { send in + for color in [Color.red, .blue, .green, .orange, .pink, .purple, .yellow, .black] { + await send(.setColor(color), animation: .linear) + try await self.clock.sleep(for: .seconds(1)) + } + } + .cancellable(id: CancelID.rainbow) + + case .resetButtonTapped: + state.alert = AlertState { + TextState("Reset state?") + } actions: { + ButtonState( + role: .destructive, + action: .send(.resetConfirmationButtonTapped, animation: .default) + ) { + TextState("Reset") + } + ButtonState(role: .cancel) { + TextState("Cancel") + } + } + return .none + + case let .setColor(color): + state.circleColor = color + return .none + + case let .tapped(point): + state.circleCenter = point + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } +} + +// MARK: - Feature view + +struct AnimationsView: View { + @State var store = Store(initialState: Animations.State()) { + Animations() + } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + VStack(alignment: .leading) { + Text(template: readMe, .body) + .padding() + .gesture( + DragGesture(minimumDistance: 0).onChanged { gesture in + viewStore.send( + .tapped(gesture.location), + animation: .interactiveSpring(response: 0.25, dampingFraction: 0.1) + ) + } + ) + .overlay { + GeometryReader { proxy in + Circle() + .fill(viewStore.circleColor) + .colorInvert() + .blendMode(.difference) + .frame(width: 50, height: 50) + .scaleEffect(viewStore.isCircleScaled ? 2 : 1) + .position( + x: viewStore.circleCenter?.x ?? proxy.size.width / 2, + y: viewStore.circleCenter?.y ?? proxy.size.height / 2 + ) + .offset(y: viewStore.circleCenter == nil ? 0 : -44) + } + .allowsHitTesting(false) + } + Toggle( + "Big mode", + isOn: + viewStore + .binding(get: \.isCircleScaled, send: { .circleScaleToggleChanged($0) }) + .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(store: self.store.scope(state: \.$alert, action: \.alert)) + .navigationBarTitleDisplayMode(.inline) + } + } +} + +// MARK: - SwiftUI previews + +struct AnimationsView_Previews: PreviewProvider { + static var previews: some View { + Group { + NavigationView { + AnimationsView( + store: Store(initialState: Animations.State()) { + Animations() + } + ) + } + + NavigationView { + AnimationsView( + store: Store(initialState: Animations.State()) { + Animations() + } + ) + } + .environment(\.colorScheme, .dark) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift new file mode 100644 index 00000000..cc14a93d --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift @@ -0,0 +1,140 @@ +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. + """ + +// MARK: - Feature domain + +@Reducer +struct BindingBasics { + struct State: Equatable { + var sliderValue = 5.0 + var stepCount = 10 + var text = "" + var toggleIsOn = false + } + + enum Action { + case sliderValueChanged(Double) + case stepCountChanged(Int) + case textChanged(String) + case toggleChanged(isOn: Bool) + } + + var body: some Reducer { + Reduce { 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 + } + } + } +} + +// MARK: - Feature view + +struct BindingBasicsView: View { + @State var store = Store(initialState: BindingBasics.State()) { + BindingBasics() + } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + HStack { + TextField( + "Type here", + text: viewStore.binding(get: \.text, send: { .textChanged($0) }) + ) + .disableAutocorrection(true) + .foregroundStyle(viewStore.toggleIsOn ? Color.secondary : .primary) + Text(alternate(viewStore.text)) + } + .disabled(viewStore.toggleIsOn) + + Toggle( + "Disable other controls", + isOn: viewStore.binding(get: \.toggleIsOn, send: { .toggleChanged(isOn: $0) }) + .resignFirstResponder() + ) + + Stepper( + "Max slider value: \(viewStore.stepCount)", + value: viewStore.binding(get: \.stepCount, send: { .stepCountChanged($0) }), + in: 0...100 + ) + .disabled(viewStore.toggleIsOn) + + HStack { + Text("Slider value: \(Int(viewStore.sliderValue))") + Slider( + value: viewStore.binding(get: \.sliderValue, send: { .sliderValueChanged($0) }), + in: 0...Double(viewStore.stepCount) + ) + .tint(.accentColor) + } + .disabled(viewStore.toggleIsOn) + } + } + .monospacedDigit() + .navigationTitle("Bindings basics") + } +} + +private func alternate(_ string: String) -> String { + string + .enumerated() + .map { idx, char in + idx.isMultiple(of: 2) + ? char.uppercased() + : char.lowercased() + } + .joined() +} + +// MARK: - SwiftUI previews + +struct BindingBasicsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BindingBasicsView( + store: Store(initialState: BindingBasics.State()) { + BindingBasics() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift new file mode 100644 index 00000000..0cc6fcae --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift @@ -0,0 +1,124 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This file demonstrates how to handle two-way bindings in the Composable Architecture using \ + binding state and actions. + + Binding 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 binding state can be \ + safeguarded with the `BindingState` property wrapper. + + It is instructive to compare this case study to the "Binding Basics" case study. + """ + +// MARK: - Feature domain + +@Reducer +struct BindingForm { + struct State: Equatable { + @BindingState var sliderValue = 5.0 + @BindingState var stepCount = 10 + @BindingState var text = "" + @BindingState var toggleIsOn = false + } + + enum Action: BindableAction { + case binding(BindingAction) + case resetButtonTapped + } + + var body: some Reducer { + BindingReducer() + Reduce { 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 = State() + return .none + } + } + } +} + +// MARK: - Feature view + +struct BindingFormView: View { + @State var store = Store(initialState: BindingForm.State()) { + BindingForm() + } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + HStack { + TextField("Type here", text: viewStore.$text) + .disableAutocorrection(true) + .foregroundStyle(viewStore.toggleIsOn ? Color.secondary : .primary) + Text(alternate(viewStore.text)) + } + .disabled(viewStore.toggleIsOn) + + Toggle("Disable other controls", isOn: viewStore.$toggleIsOn.resignFirstResponder()) + + Stepper( + "Max slider value: \(viewStore.stepCount)", + value: viewStore.$stepCount, + in: 0...100 + ) + .disabled(viewStore.toggleIsOn) + + HStack { + Text("Slider value: \(Int(viewStore.sliderValue))") + + Slider(value: viewStore.$sliderValue, in: 0...Double(viewStore.stepCount)) + .tint(.accentColor) + } + .disabled(viewStore.toggleIsOn) + + Button("Reset") { + viewStore.send(.resetButtonTapped) + } + .tint(.red) + } + } + .monospacedDigit() + .navigationTitle("Bindings form") + } +} + +private func alternate(_ string: String) -> String { + string + .enumerated() + .map { idx, char in + idx.isMultiple(of: 2) + ? char.uppercased() + : char.lowercased() + } + .joined() +} + +// MARK: - SwiftUI previews + +struct BindingFormView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BindingFormView( + store: Store(initialState: BindingForm.State()) { + BindingForm() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift new file mode 100644 index 00000000..b2ba1ca2 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift @@ -0,0 +1,100 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how to take small features and compose them into bigger ones using reducer builders and the `Scope` reducer, as well as the `scope` operator on stores. + + It reuses the domain of the counter screen and embeds it, twice, in a larger domain. + """ + +// MARK: - Feature domain + +@Reducer +struct TwoCounters { + @ObservableState + struct State: Equatable { + var counter1 = Counter.State() + var counter2 = Counter.State() + var isDisplaySum = true + } + + enum Action { + case counter1(Counter.Action) + case counter2(Counter.Action) + case toggleSumDisplay + } + + var body: some Reducer { + Scope(state: \.counter1, action: \.counter1) { + Counter() + } + Scope(state: \.counter2, action: \.counter2) { + Counter() + } + Reduce { state, action in + switch action { + case .counter1: + return .none + case .counter2: + return .none + case .toggleSumDisplay: + state.isDisplaySum.toggle() + return .none + } + } + } +} + +// MARK: - Feature view + +struct TwoCountersView: View { + @State var store = Store(initialState: TwoCounters.State()) { + TwoCounters() + } + + var body: some View { + let _ = Self._printChanges() + Form { + Section { + AboutView(readMe: readMe) + } + + HStack { + Text("Counter 1") + Spacer() + CounterView(store: self.store.scope(state: \.counter1, action: \.counter1)) + } + + HStack { + Text("Counter 2") + Spacer() + CounterView(store: self.store.scope(state: \.counter2, action: \.counter2)) + } + + Section { + if self.store.isDisplaySum { + Text("Sum: \(self.store.counter1.count + self.store.counter2.count)") + } + Button("Toggle sum") { + self.store.send(.toggleSumDisplay) + } + } + } + .buttonStyle(.borderless) + .navigationTitle("Two counters demo") + } +} + +// MARK: - SwiftUI previews + +struct TwoCountersView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + TwoCountersView( + store: Store(initialState: TwoCounters.State()) { + TwoCounters() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift new file mode 100644 index 00000000..71f69752 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift @@ -0,0 +1,120 @@ +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. + """ + +// MARK: - Feature domain + +@Reducer +struct Counter { + @ObservableState + struct State: Equatable { + var count = 0 + var isDisplayingCount = true + } + + enum Action { + case decrementButtonTapped + case incrementButtonTapped + case toggleIsDisplayingCount + case resetButtonTapped + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .decrementButtonTapped: + state.count -= 1 + return .none + case .incrementButtonTapped: + state.count += 1 + return .none + case .toggleIsDisplayingCount: + state.isDisplayingCount.toggle() + return .none + case .resetButtonTapped: + state = State() + return .none + } + } + } +} + +// MARK: - Feature view + +struct CounterView: View { + let store: StoreOf + + var body: some View { + let _ = Self._printChanges() + VStack { + HStack { + Button { + self.store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } + + if self.store.isDisplayingCount { + Text("\(self.store.count)") + .monospacedDigit() + } + + Button { + self.store.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } + } + .padding() + + Button("Toggle count display") { + self.store.send(.toggleIsDisplayingCount) + } + Button("Reset") { + self.store.send(.resetButtonTapped) + } + } + } +} + +struct CounterDemoView: View { + @State var store = Store(initialState: Counter.State()) { + Counter() + } + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + Section { + CounterView(store: self.store) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderless) + .navigationTitle("Counter demo") + } +} + +// MARK: - SwiftUI previews + +struct CounterView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + CounterDemoView( + store: Store(initialState: Counter.State()) { + Counter() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift new file mode 100644 index 00000000..d1ca9170 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift @@ -0,0 +1,92 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This demonstrates how to make use of SwiftUI's `@FocusState` in the Composable Architecture with \ + the library's `bind` view modifier. If you tap the "Sign in" button while a field is empty, the \ + focus will be changed to that field. + """ + +// MARK: - Feature domain + +@Reducer +struct FocusDemo { + struct State: Equatable { + @BindingState var focusedField: Field? + @BindingState var password: String = "" + @BindingState var username: String = "" + + enum Field: String, Hashable { + case username, password + } + } + + enum Action: BindableAction { + case binding(BindingAction) + case signInButtonTapped + } + + var body: some Reducer { + BindingReducer() + Reduce { 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 + } + } + } +} + +// MARK: - Feature view + +struct FocusDemoView: View { + @State var store = Store(initialState: FocusDemo.State()) { + FocusDemo() + } + @FocusState var focusedField: FocusDemo.State.Field? + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + AboutView(readMe: readMe) + + VStack { + TextField("Username", text: viewStore.$username) + .focused($focusedField, equals: .username) + SecureField("Password", text: viewStore.$password) + .focused($focusedField, equals: .password) + Button("Sign In") { + viewStore.send(.signInButtonTapped) + } + .buttonStyle(.borderedProminent) + } + .textFieldStyle(.roundedBorder) + } + // Synchronize store focus state and local focus state. + .bind(viewStore.$focusedField, to: self.$focusedField) + } + .navigationTitle("Focus demo") + } +} + +// MARK: - SwiftUI previews + +struct FocusDemo_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + FocusDemoView( + store: Store(initialState: FocusDemo.State()) { + FocusDemo() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift new file mode 100644 index 00000000..60538234 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift @@ -0,0 +1,126 @@ +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 `Counter.State?` 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. + """ + +// MARK: - Feature domain + +@Reducer +struct OptionalBasics { + @ObservableState + struct State: Equatable { + var nonOptionalCounter = Counter.State() + var optionalCounter: Counter.State? + } + + enum Action { + case nonOptionalCounter(Counter.Action) + case optionalCounter(Counter.Action) + case toggleCounterButtonTapped + } + + var body: some Reducer { + Scope(state: \.nonOptionalCounter, action: \.nonOptionalCounter) { + Counter() + } + Reduce { state, action in + switch action { + case .nonOptionalCounter: + return .none + case .toggleCounterButtonTapped: + state.optionalCounter = + state.optionalCounter == nil + ? Counter.State() + : nil + return .none + case .optionalCounter: + return .none + } + } + .ifLet(\.optionalCounter, action: \.optionalCounter) { + Counter() + } + } +} + +// MARK: - Feature view + +struct OptionalBasicsView: View { + @State var store = Store(initialState: OptionalBasics.State()) { + OptionalBasics() + } + + var body: some View { + let _ = Self._printChanges() + Form { + Section { + AboutView(readMe: readMe) + } + + Button("Toggle counter state") { + self.store.send(.toggleCounterButtonTapped) + } + + if let store = self.store.scope(state: \.optionalCounter, action: \.optionalCounter) { + Text(template: "`Counter.State` is non-`nil`") + CounterView(store: store) + .buttonStyle(.borderless) + .frame(maxWidth: .infinity) + } else { + Text(template: "`Counter.State` is `nil`") + } + + Section { + CounterView( + store: self.store.scope(state: \.nonOptionalCounter, action: \.nonOptionalCounter) + ) + .buttonStyle(.borderless) + .frame(maxWidth: .infinity) + } + + Section { + Text( + ((self.store.optionalCounter?.count ?? 0) + self.store.nonOptionalCounter.count).description + ) + } header: { + Text("Sum") + } + } + .navigationTitle("Optional state") + } +} + +// MARK: - SwiftUI previews + +struct OptionalBasicsView_Previews: PreviewProvider { + static var previews: some View { + Group { + NavigationView { + OptionalBasicsView( + store: Store(initialState: OptionalBasics.State()) { + OptionalBasics() + } + ) + } + + NavigationView { + OptionalBasicsView( + store: Store( + initialState: OptionalBasics.State(optionalCounter: Counter.State(count: 42)) + ) { + OptionalBasics() + } + ) + } + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift new file mode 100644 index 00000000..eacbe671 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift @@ -0,0 +1,290 @@ +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. + """ + +// MARK: - Feature domain + +@Reducer +struct SharedState { + enum Tab { case counter, profile } + + struct State: Equatable { + var counter = Counter.State() + var currentTab = Tab.counter + + /// The Profile.State can be derived from the Counter.State 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: Profile.State { + get { + Profile.State( + 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 + } + } + } + + enum Action { + case counter(Counter.Action) + case profile(Profile.Action) + case selectTab(Tab) + } + + var body: some Reducer { + Scope(state: \.counter, action: \.counter) { + Counter() + } + + Scope(state: \.profile, action: \.profile) { + Profile() + } + + Reduce { state, action in + switch action { + case .counter, .profile: + return .none + case let .selectTab(tab): + state.currentTab = tab + return .none + } + } + } + + @Reducer + struct Counter { + struct State: Equatable { + @PresentationState var alert: AlertState? + var count = 0 + var maxCount = 0 + var minCount = 0 + var numberOfCounts = 0 + } + + enum Action { + case alert(PresentationAction) + case decrementButtonTapped + case incrementButtonTapped + case isPrimeButtonTapped + + enum Alert: Equatable {} + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .alert: + 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 { + TextState( + isPrime(state.count) + ? "👍 The number \(state.count) is prime!" + : "👎 The number \(state.count) is not prime :(" + ) + } + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } + } + + @Reducer + struct Profile { + struct State: 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 Action { + case resetCounterButtonTapped + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .resetCounterButtonTapped: + state.resetCount() + return .none + } + } + } + } +} + +// MARK: - Feature view + +struct SharedStateView: View { + @State var store = Store(initialState: SharedState.State()) { + SharedState() + } + + var body: some View { + WithViewStore(self.store, observe: \.currentTab) { viewStore in + VStack { + Picker("Tab", selection: viewStore.binding(send: { .selectTab($0) })) { + 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: \.counter) + ) + } + + if viewStore.state == .profile { + SharedStateProfileView( + store: self.store.scope(state: \.profile, action: \.profile) + ) + } + + Spacer() + } + } + .padding() + } +} + +struct SharedStateCounterView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + VStack(spacing: 64) { + Text(template: readMe, .caption) + + VStack(spacing: 16) { + HStack { + Button { + viewStore.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } + + Text("\(viewStore.count)") + .monospacedDigit() + + Button { + viewStore.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } + } + + Button("Is this prime?") { viewStore.send(.isPrimeButtonTapped) } + } + } + .padding(.top) + .navigationTitle("Shared State Demo") + .alert(store: self.store.scope(state: \.$alert, action: \.alert)) + } + } +} + +struct SharedStateProfileView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { 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(.top) + .navigationTitle("Profile") + } + } +} + +// MARK: - SwiftUI previews + +struct SharedState_Previews: PreviewProvider { + static var previews: some View { + SharedStateView( + store: Store(initialState: SharedState.State()) { + SharedState() + } + ) + } +} + +// 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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift new file mode 100644 index 00000000..f822e5fd --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift @@ -0,0 +1,170 @@ +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 clock 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 + +@Reducer +struct EffectsBasics { + struct State: Equatable { + var count = 0 + var isNumberFactRequestInFlight = false + var numberFact: String? + } + + enum Action { + case decrementButtonTapped + case decrementDelayResponse + case incrementButtonTapped + case numberFactButtonTapped + case numberFactResponse(Result) + } + + @Dependency(\.continuousClock) var clock + @Dependency(\.factClient) var factClient + private enum CancelID { case delay } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .decrementButtonTapped: + state.count -= 1 + state.numberFact = nil + // Return an effect that re-increments the count after 1 second if the count is negative + return state.count >= 0 + ? .none + : .run { send in + try await self.clock.sleep(for: .seconds(1)) + await send(.decrementDelayResponse) + } + .cancellable(id: CancelID.delay) + + case .decrementDelayResponse: + if state.count < 0 { + state.count += 1 + } + return .none + + case .incrementButtonTapped: + state.count += 1 + state.numberFact = nil + return state.count >= 0 + ? .cancel(id: CancelID.delay) + : .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 .run { [count = state.count] send in + await send(.numberFactResponse(Result { try await self.factClient.fetch(count) })) + } + + 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 { + @State var store = Store(initialState: EffectsBasics.State()) { + EffectsBasics() + } + @Environment(\.openURL) var openURL + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + Section { + HStack { + Button { + viewStore.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } + + Text("\(viewStore.count)") + .monospacedDigit() + + Button { + viewStore.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } + } + .frame(maxWidth: .infinity) + + 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") { + self.openURL(URL(string: "http://numbersapi.com")!) + } + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderless) + } + .navigationTitle("Effects") + } +} + +// MARK: - SwiftUI previews + +struct EffectsBasicsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EffectsBasicsView( + store: Store(initialState: EffectsBasics.State()) { + EffectsBasics() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift new file mode 100644 index 00000000..07f0f292 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift @@ -0,0 +1,136 @@ +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: - Feature domain + +@Reducer +struct EffectsCancellation { + struct State: Equatable { + var count = 0 + var currentFact: String? + var isFactRequestInFlight = false + } + + enum Action { + case cancelButtonTapped + case stepperChanged(Int) + case factButtonTapped + case factResponse(Result) + } + + @Dependency(\.factClient) var factClient + private enum CancelID { case factRequest } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .cancelButtonTapped: + state.isFactRequestInFlight = false + return .cancel(id: CancelID.factRequest) + + case let .stepperChanged(value): + state.count = value + state.currentFact = nil + state.isFactRequestInFlight = false + return .cancel(id: CancelID.factRequest) + + case .factButtonTapped: + state.currentFact = nil + state.isFactRequestInFlight = true + + return .run { [count = state.count] send in + await send(.factResponse(Result { try await self.factClient.fetch(count) })) + } + .cancellable(id: CancelID.factRequest) + + case let .factResponse(.success(response)): + state.isFactRequestInFlight = false + state.currentFact = response + return .none + + case .factResponse(.failure): + state.isFactRequestInFlight = false + return .none + } + } + } +} + +// MARK: - Feature view + +struct EffectsCancellationView: View { + @State var store = Store(initialState: EffectsCancellation.State()) { + EffectsCancellation() + } + @Environment(\.openURL) var openURL + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + Section { + Stepper( + "\(viewStore.count)", + value: viewStore.binding(get: \.count, send: { .stepperChanged($0) }) + ) + + if viewStore.isFactRequestInFlight { + 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(.factButtonTapped) } + .disabled(viewStore.isFactRequestInFlight) + } + + viewStore.currentFact.map { + Text($0).padding(.vertical, 8) + } + } + + Section { + Button("Number facts provided by numbersapi.com") { + self.openURL(URL(string: "http://numbersapi.com")!) + } + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderless) + } + .navigationTitle("Effect cancellation") + } +} + +// MARK: - SwiftUI previews + +struct EffectsCancellation_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EffectsCancellationView( + store: Store(initialState: EffectsCancellation.State()) { + EffectsCancellation() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift new file mode 100644 index 00000000..84e507d0 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift @@ -0,0 +1,127 @@ +import ComposableArchitecture +import SwiftUI +import XCTestDynamicOverlay + +private let readMe = """ + This application demonstrates how to handle long-living effects, for example notifications from \ + Notification Center, and how to tie an effect's lifetime to the lifetime of the view. + + 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. The notifications effect is automatically cancelled when leaving \ + the screen, and restarted when entering the screen. + """ + +// MARK: - Feature domain + +@Reducer +struct LongLivingEffects { + struct State: Equatable { + var screenshotCount = 0 + } + + enum Action { + case task + case userDidTakeScreenshotNotification + } + + @Dependency(\.screenshots) var screenshots + + var body: some Reducer { + Reduce { state, action in + switch action { + case .task: + // When the view appears, start the effect that emits when screenshots are taken. + return .run { send in + for await _ in await self.screenshots() { + await send(.userDidTakeScreenshotNotification) + } + } + + case .userDidTakeScreenshotNotification: + state.screenshotCount += 1 + return .none + } + } + } +} + +extension DependencyValues { + var screenshots: @Sendable () async -> AsyncStream { + get { self[ScreenshotsKey.self] } + set { self[ScreenshotsKey.self] = newValue } + } +} + +private enum ScreenshotsKey: DependencyKey { + static let liveValue: @Sendable () async -> AsyncStream = { + await AsyncStream( + NotificationCenter.default + .notifications(named: UIApplication.userDidTakeScreenshotNotification) + .map { _ in } + ) + } + static let testValue: @Sendable () async -> AsyncStream = unimplemented( + #"@Dependency(\.screenshots)"#, placeholder: .finished + ) +} + +// MARK: - Feature view + +struct LongLivingEffectsView: View { + @State var store = Store(initialState: LongLivingEffects.State()) { + LongLivingEffects() + } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { 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") + } + } + } + .navigationTitle("Long-living effects") + .task { await viewStore.send(.task).finish() } + } + } + + 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) + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - SwiftUI previews + +struct EffectsLongLiving_Previews: PreviewProvider { + static var previews: some View { + let appView = LongLivingEffectsView( + store: Store(initialState: LongLivingEffects.State()) { + LongLivingEffects() + } + ) + + return Group { + NavigationView { appView } + NavigationView { appView.detailView } + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift new file mode 100644 index 00000000..054f1de2 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift @@ -0,0 +1,133 @@ +import ComposableArchitecture +@preconcurrency import SwiftUI + +private let 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. + """ + +// MARK: - Feature domain + +@Reducer +struct Refreshable { + struct State: Equatable { + var count = 0 + var fact: String? + } + + enum Action { + case cancelButtonTapped + case decrementButtonTapped + case factResponse(Result) + case incrementButtonTapped + case refresh + } + + @Dependency(\.factClient) var factClient + private enum CancelID { case factRequest } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .cancelButtonTapped: + return .cancel(id: CancelID.factRequest) + + case .decrementButtonTapped: + state.count -= 1 + return .none + + case let .factResponse(.success(fact)): + state.fact = fact + return .none + + case .factResponse(.failure): + // NB: This is where you could do some error handling. + return .none + + case .incrementButtonTapped: + state.count += 1 + return .none + + case .refresh: + state.fact = nil + return .run { [count = state.count] send in + await send( + .factResponse(Result { try await self.factClient.fetch(count) }), + animation: .default + ) + } + .cancellable(id: CancelID.factRequest) + } + } + } +} + +// MARK: - Feature view + +struct RefreshableView: View { + @State var store = Store(initialState: Refreshable.State()) { + Refreshable() + } + @State var isLoading = false + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + List { + Section { + AboutView(readMe: readMe) + } + + HStack { + Button { + viewStore.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } + + Text("\(viewStore.count)") + .monospacedDigit() + + Button { + viewStore.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } + } + .frame(maxWidth: .infinity) + .buttonStyle(.borderless) + + if let fact = viewStore.fact { + Text(fact) + .bold() + } + if self.isLoading { + Button("Cancel") { + viewStore.send(.cancelButtonTapped, animation: .default) + } + } + } + .refreshable { + self.isLoading = true + defer { self.isLoading = false } + await viewStore.send(.refresh).finish() + } + } + } +} + +// MARK: - SwiftUI previews + +struct Refreshable_Previews: PreviewProvider { + static var previews: some View { + RefreshableView( + store: Store(initialState: Refreshable.State()) { + Refreshable() + } + ) + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift new file mode 100644 index 00000000..294498ba --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift @@ -0,0 +1,243 @@ +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: @Sendable () async throws -> Int +} + +let multipleDependenciesReducer = Reducer< + MultipleDependenciesState, + MultipleDependenciesAction, + SystemEnvironment +> { state, action, environment in + + switch action { + case .alertButtonTapped: + return .task { + try await environment.mainQueue.sleep(for: 1) + return .alertDelayReceived + } + + 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 .task { .fetchNumberResponse(try await environment.fetchNumber()) } + + 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, observe: { $0 }) { viewStore in + Form { + AboutView(readMe: readMe) + + Section { + HStack { + Button("Date") { viewStore.send(.dateButtonTapped) } + if let dateString = viewStore.dateString { + Spacer() + Text(dateString) + } + } + + HStack { + Button("UUID") { viewStore.send(.uuidButtonTapped) } + if let uuidString = viewStore.uuidString { + Spacer() + Text(uuidString) + .minimumScaleFactor(0.5) + .lineLimit(1) + } + } + + Button("Delayed Alert") { viewStore.send(.alertButtonTapped) } + .alert(self.store.scope(state: \.alert), dismiss: .alertDismissed) + } header: { + Text( + template: """ + The actions below make use of the dependencies in the `SystemEnvironment`. + """, .caption + ) + .textCase(.none) + } + + Section { + HStack { + Button("Fetch Number") { viewStore.send(.fetchNumberButtonTapped) } + .disabled(viewStore.isFetchInFlight) + Spacer() + + if viewStore.isFetchInFlight { + ProgressView() + } else if let fetchedNumberString = viewStore.fetchedNumberString { + Text(fetchedNumberString) + } + } + } 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 + ) + .textCase(.none) + } + + } + .buttonStyle(.borderless) + } + .navigationTitle("System Environment") + } +} + +struct MultipleDependenciesView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + MultipleDependenciesView( + store: Store( + initialState: MultipleDependenciesState(), + reducer: multipleDependenciesReducer, + environment: .live( + environment: MultipleDependenciesEnvironment( + fetchNumber: { + try await Task.sleep(nanoseconds: NSEC_PER_SEC) + return Int.random(in: 1...1_000) + } + ) + ) + ) + ) + } + } +} + +@dynamicMemberLookup +struct SystemEnvironment { + var date: @Sendable () -> Date + var environment: Environment + var mainQueue: AnySchedulerOf + var uuid: @Sendable () -> 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() }, + environment: environment, + mainQueue: .main, + uuid: { UUID() } + ) + } + + /// 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 + ) + } +} + +extension SystemEnvironment: Sendable where Environment: Sendable {} + +#if DEBUG + import XCTestDynamicOverlay + + extension SystemEnvironment { + static func unimplemented( + date: @escaping @Sendable () -> Date = XCTUnimplemented( + "\(Self.self).date", placeholder: Date() + ), + environment: Environment, + mainQueue: AnySchedulerOf = .unimplemented, + uuid: @escaping @Sendable () -> 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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift new file mode 100644 index 00000000..f0e9ebb4 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift @@ -0,0 +1,134 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This application demonstrates how to work with timers in the Composable Architecture. + + It makes use of the `.timer` method on clocks, which is a helper provided by the Swift Clocks \ + library included with this library. The helper provides an `AsyncSequence`-friendly API for \ + dealing with times in asynchronous code. + """ + +// MARK: - Feature domain + +@Reducer +struct Timers { + struct State: Equatable { + var isTimerActive = false + var secondsElapsed = 0 + } + + enum Action { + case onDisappear + case timerTicked + case toggleTimerButtonTapped + } + + @Dependency(\.continuousClock) var clock + private enum CancelID { case timer } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .onDisappear: + return .cancel(id: CancelID.timer) + + case .timerTicked: + state.secondsElapsed += 1 + return .none + + case .toggleTimerButtonTapped: + state.isTimerActive.toggle() + return .run { [isTimerActive = state.isTimerActive] send in + guard isTimerActive else { return } + for await _ in self.clock.timer(interval: .seconds(1)) { + await send(.timerTicked, animation: .interpolatingSpring(stiffness: 3000, damping: 40)) + } + } + .cancellable(id: CancelID.timer, cancelInFlight: true) + } + } + } +} + +// MARK: - Feature view + +struct TimersView: View { + @State var store = Store(initialState: Timers.State()) { + Timers() + } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + AboutView(readMe: readMe) + + 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(.primary, lineWidth: 3) + .rotationEffect(.degrees(Double(viewStore.secondsElapsed) * 360 / 60)) + } + } + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: 280) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + + Button { + viewStore.send(.toggleTimerButtonTapped) + } label: { + Text(viewStore.isTimerActive ? "Stop" : "Start") + .padding(8) + } + .frame(maxWidth: .infinity) + .tint(viewStore.isTimerActive ? Color.red : .accentColor) + .buttonStyle(.borderedProminent) + } + .navigationTitle("Timers") + .onDisappear { + viewStore.send(.onDisappear) + } + } + } +} + +// MARK: - SwiftUI previews + +struct TimersView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + TimersView( + store: Store(initialState: Timers.State()) { + Timers() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift new file mode 100644 index 00000000..2e721cd5 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift @@ -0,0 +1,373 @@ +import ComposableArchitecture +import SwiftUI +import XCTestDynamicOverlay + +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 sent in. + """ + +// MARK: - Feature domain + +@Reducer +struct WebSocket { + struct State: Equatable { + @PresentationState var alert: AlertState? + var connectivityState = ConnectivityState.disconnected + var messageToSend = "" + var receivedMessages: [String] = [] + + enum ConnectivityState: String { + case connected + case connecting + case disconnected + } + } + + enum Action { + case alert(PresentationAction) + case connectButtonTapped + case messageToSendChanged(String) + case receivedSocketMessage(Result) + case sendButtonTapped + case sendResponse(didSucceed: Bool) + case webSocket(WebSocketClient.Action) + + enum Alert: Equatable {} + } + + @Dependency(\.continuousClock) var clock + @Dependency(\.webSocket) var webSocket + + var body: some Reducer { + Reduce { state, action in + switch action { + case .alert: + return .none + + case .connectButtonTapped: + switch state.connectivityState { + case .connected, .connecting: + state.connectivityState = .disconnected + return .cancel(id: WebSocketClient.ID()) + + case .disconnected: + state.connectivityState = .connecting + return .run { send in + let actions = await self.webSocket + .open(WebSocketClient.ID(), URL(string: "wss://echo.websocket.events")!, []) + await withThrowingTaskGroup(of: Void.self) { group in + for await action in actions { + // NB: Can't call `await send` here outside of `group.addTask` due to task local + // dependency mutation in `Effect.{task,run}`. Can maybe remove that explicit task + // local mutation (and this `addTask`?) in a world with + // `Effect(operation: .run { ... })`? + group.addTask { await send(.webSocket(action)) } + switch action { + case .didOpen: + group.addTask { + while !Task.isCancelled { + try await self.clock.sleep(for: .seconds(10)) + try? await self.webSocket.sendPing(WebSocketClient.ID()) + } + } + group.addTask { + for await result in try await self.webSocket.receive(WebSocketClient.ID()) { + await send(.receivedSocketMessage(result)) + } + } + case .didClose: + return + } + } + } + } + .cancellable(id: WebSocketClient.ID()) + } + + case let .messageToSendChanged(message): + state.messageToSend = message + return .none + + case let .receivedSocketMessage(.success(message)): + if case let .string(string) = message { + state.receivedMessages.append(string) + } + return .none + + case .receivedSocketMessage(.failure): + return .none + + case .sendButtonTapped: + let messageToSend = state.messageToSend + state.messageToSend = "" + return .run { send in + try await self.webSocket.send(WebSocketClient.ID(), .string(messageToSend)) + await send(.sendResponse(didSucceed: true)) + } catch: { _, send in + await send(.sendResponse(didSucceed: false)) + } + .cancellable(id: WebSocketClient.ID()) + + case .sendResponse(didSucceed: false): + state.alert = AlertState { + TextState("Could not send socket message. Connect to the server first, and try again.") + } + return .none + + case .sendResponse(didSucceed: true): + return .none + + case .webSocket(.didClose): + state.connectivityState = .disconnected + return .cancel(id: WebSocketClient.ID()) + + case .webSocket(.didOpen): + state.connectivityState = .connected + state.receivedMessages.removeAll() + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } +} + +// MARK: - Feature view + +struct WebSocketView: View { + @State var store = Store(initialState: WebSocket.State()) { + WebSocket() + } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + Section { + VStack(alignment: .leading) { + Button( + viewStore.connectivityState == .connected + ? "Disconnect" + : viewStore.connectivityState == .disconnected + ? "Connect" + : "Connecting..." + ) { + viewStore.send(.connectButtonTapped) + } + .buttonStyle(.bordered) + .tint(viewStore.connectivityState == .connected ? .red : .green) + + HStack { + TextField( + "Type message here", + text: viewStore.binding(get: \.messageToSend, send: { .messageToSendChanged($0) }) + ) + .textFieldStyle(.roundedBorder) + + Button("Send") { + viewStore.send(.sendButtonTapped) + } + .buttonStyle(.borderless) + } + } + } + + Section { + Text("Status: \(viewStore.connectivityState.rawValue)") + .foregroundStyle(.secondary) + Text(viewStore.receivedMessages.reversed().joined(separator: "\n")) + } header: { + Text("Received messages") + } + } + .alert(store: self.store.scope(state: \.$alert, action: \.alert)) + .navigationTitle("Web Socket") + } + } +} + +// MARK: - WebSocketClient + +struct WebSocketClient { + struct ID: Hashable, @unchecked Sendable { + let rawValue: AnyHashable + + init(_ rawValue: RawValue) { + self.rawValue = rawValue + } + + init() { + struct RawValue: Hashable, Sendable {} + self.rawValue = RawValue() + } + } + + @CasePathable + enum Action { + case didOpen(protocol: String?) + case didClose(code: URLSessionWebSocketTask.CloseCode, reason: Data?) + } + + @CasePathable + enum Message: Equatable { + struct Unknown: Error {} + + case data(Data) + case string(String) + + init(_ message: URLSessionWebSocketTask.Message) throws { + switch message { + case let .data(data): self = .data(data) + case let .string(string): self = .string(string) + @unknown default: throw Unknown() + } + } + } + + var open: @Sendable (ID, URL, [String]) async -> AsyncStream + var receive: @Sendable (ID) async throws -> AsyncStream> + var send: @Sendable (ID, URLSessionWebSocketTask.Message) async throws -> Void + var sendPing: @Sendable (ID) async throws -> Void +} + +extension WebSocketClient: DependencyKey { + static var liveValue: Self { + return Self( + open: { await WebSocketActor.shared.open(id: $0, url: $1, protocols: $2) }, + receive: { try await WebSocketActor.shared.receive(id: $0) }, + send: { try await WebSocketActor.shared.send(id: $0, message: $1) }, + sendPing: { try await WebSocketActor.shared.sendPing(id: $0) } + ) + + final actor WebSocketActor: GlobalActor { + final class Delegate: NSObject, URLSessionWebSocketDelegate { + var continuation: AsyncStream.Continuation? + + func urlSession( + _: URLSession, + webSocketTask _: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String? + ) { + self.continuation?.yield(.didOpen(protocol: `protocol`)) + } + + func urlSession( + _: URLSession, + webSocketTask _: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data? + ) { + self.continuation?.yield(.didClose(code: closeCode, reason: reason)) + self.continuation?.finish() + } + } + + typealias Dependencies = (socket: URLSessionWebSocketTask, delegate: Delegate) + + static let shared = WebSocketActor() + + var dependencies: [ID: Dependencies] = [:] + + func open(id: ID, url: URL, protocols: [String]) -> AsyncStream { + let delegate = Delegate() + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + let socket = session.webSocketTask(with: url, protocols: protocols) + defer { socket.resume() } + var continuation: AsyncStream.Continuation! + let stream = AsyncStream { + $0.onTermination = { _ in + socket.cancel() + Task { await self.removeDependencies(id: id) } + } + continuation = $0 + } + delegate.continuation = continuation + self.dependencies[id] = (socket, delegate) + return stream + } + + func close( + id: ID, with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data? + ) async throws { + defer { self.dependencies[id] = nil } + try self.socket(id: id).cancel(with: closeCode, reason: reason) + } + + func receive(id: ID) throws -> AsyncStream> { + let socket = try self.socket(id: id) + return AsyncStream { continuation in + let task = Task { + while !Task.isCancelled { + continuation.yield(await Result { try await Message(socket.receive()) }) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + func send(id: ID, message: URLSessionWebSocketTask.Message) async throws { + try await self.socket(id: id).send(message) + } + + func sendPing(id: ID) async throws { + let socket = try self.socket(id: id) + return try await withCheckedThrowingContinuation { continuation in + socket.sendPing { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + + private func socket(id: ID) throws -> URLSessionWebSocketTask { + guard let dependencies = self.dependencies[id]?.socket else { + struct Closed: Error {} + throw Closed() + } + return dependencies + } + + private func removeDependencies(id: ID) { + self.dependencies[id] = nil + } + } + } + + static let testValue = Self( + open: unimplemented("\(Self.self).open", placeholder: AsyncStream.never), + receive: unimplemented("\(Self.self).receive"), + send: unimplemented("\(Self.self).send"), + sendPing: unimplemented("\(Self.self).sendPing") + ) +} + +extension DependencyValues { + var webSocket: WebSocketClient { + get { self[WebSocketClient.self] } + set { self[WebSocketClient.self] = newValue } + } +} + +// MARK: - SwiftUI previews + +struct WebSocketView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + WebSocketView( + store: Store(initialState: WebSocket.State(receivedMessages: ["Hi"])) { + WebSocket() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift new file mode 100644 index 00000000..1804665a --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift @@ -0,0 +1,130 @@ +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. + """ + +// MARK: - Feature domain + +@Reducer +struct NavigateAndLoadList { + struct State: 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 Action { + case counter(Counter.Action) + case setNavigation(selection: UUID?) + case setNavigationSelectionDelayCompleted + } + + @Dependency(\.continuousClock) var clock + private enum CancelID { case load } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .counter: + return .none + + case let .setNavigation(selection: .some(id)): + state.selection = Identified(nil, id: id) + return .run { send in + try await self.clock.sleep(for: .seconds(1)) + await send(.setNavigationSelectionDelayCompleted) + } + .cancellable(id: CancelID.load, cancelInFlight: true) + + 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.load) + + case .setNavigationSelectionDelayCompleted: + guard let id = state.selection?.id else { return .none } + state.selection?.value = Counter.State(count: state.rows[id: id]?.count ?? 0) + return .none + } + } + .ifLet(\.selection, action: \.counter) { + EmptyReducer() + .ifLet(\.value, action: \.self) { + Counter() + } + } + } +} + +// MARK: - Feature view + +struct NavigateAndLoadListView: View { + @State var store = Store(initialState: NavigateAndLoadList.State()) { + NavigateAndLoadList() + } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + ForEach(viewStore.rows) { row in + NavigationLink( + "Load optional counter that starts from \(row.count)", + tag: row.id, + selection: viewStore.binding( + get: \.selection?.id, + send: { .setNavigation(selection: $0) } + ) + ) { + IfLetStore(self.store.scope(state: \.selection?.value, action: \.counter)) { + CounterView(store: $0) + } else: { + ProgressView() + } + } + } + } + } + .navigationTitle("Navigate and load") + } +} + +// MARK: - SwiftUI previews + +struct NavigateAndLoadListView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + NavigateAndLoadListView( + store: Store( + initialState: NavigateAndLoadList.State( + rows: [ + NavigateAndLoadList.State.Row(count: 1, id: UUID()), + NavigateAndLoadList.State.Row(count: 42, id: UUID()), + NavigateAndLoadList.State.Row(count: 100, id: UUID()), + ] + ) + ) { + NavigateAndLoadList() + } + ) + } + .navigationViewStyle(.stack) + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Multiple-Destinations.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Multiple-Destinations.swift new file mode 100644 index 00000000..2cbf18b1 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Multiple-Destinations.swift @@ -0,0 +1,109 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates driving 3 kinds of navigation (drill down, sheet, popover) from a single + piece of enum state. + """ + +@Reducer +struct MultipleDestinations { + @Reducer + public struct Destination { + public enum State: Equatable { + case drillDown(Counter.State) + case popover(Counter.State) + case sheet(Counter.State) + } + + public enum Action { + case drillDown(Counter.Action) + case popover(Counter.Action) + case sheet(Counter.Action) + } + + public var body: some Reducer { + Scope(state: \.drillDown, action: \.drillDown) { + Counter() + } + Scope(state: \.sheet, action: \.sheet) { + Counter() + } + Scope(state: \.popover, action: \.popover) { + Counter() + } + } + } + + struct State: Equatable { + @PresentationState var destination: Destination.State? + } + + enum Action { + case destination(PresentationAction) + case showDrillDown + case showPopover + case showSheet + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .showDrillDown: + state.destination = .drillDown(Counter.State()) + return .none + case .showPopover: + state.destination = .popover(Counter.State()) + return .none + case .showSheet: + state.destination = .sheet(Counter.State()) + return .none + case .destination: + return .none + } + } + .ifLet(\.$destination, action: \.destination) { + Destination() + } + } +} + +struct MultipleDestinationsView: View { + @State var store = Store(initialState: MultipleDestinations.State()) { + MultipleDestinations() + } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + Button("Show drill-down") { + viewStore.send(.showDrillDown) + } + Button("Show popover") { + viewStore.send(.showPopover) + } + Button("Show sheet") { + viewStore.send(.showSheet) + } + } + .navigationDestination( + store: self.store.scope(state: \.$destination.drillDown, action: \.destination.drillDown) + ) { store in + CounterView(store: store) + } + .popover( + store: self.store.scope(state: \.$destination.popover, action: \.destination.popover) + ) { store in + CounterView(store: store) + } + .sheet( + store: self.store.scope(state: \.$destination.sheet, action: \.destination.sheet) + ) { store in + CounterView(store: store) + } + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift new file mode 100644 index 00000000..6229fff2 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift @@ -0,0 +1,106 @@ +import ComposableArchitecture +import SwiftUI + +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. + """ + +// MARK: - Feature domain + +@Reducer +struct NavigateAndLoad { + struct State: Equatable { + var isNavigationActive = false + var optionalCounter: Counter.State? + } + + enum Action { + case optionalCounter(Counter.Action) + case setNavigation(isActive: Bool) + case setNavigationIsActiveDelayCompleted + } + + @Dependency(\.continuousClock) var clock + private enum CancelID { case load } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .setNavigation(isActive: true): + state.isNavigationActive = true + return .run { send in + try await self.clock.sleep(for: .seconds(1)) + await send(.setNavigationIsActiveDelayCompleted) + } + .cancellable(id: CancelID.load) + + case .setNavigation(isActive: false): + state.isNavigationActive = false + state.optionalCounter = nil + return .cancel(id: CancelID.load) + + case .setNavigationIsActiveDelayCompleted: + state.optionalCounter = Counter.State() + return .none + + case .optionalCounter: + return .none + } + } + .ifLet(\.optionalCounter, action: \.optionalCounter) { + Counter() + } + } +} + +// MARK: - Feature view + +struct NavigateAndLoadView: View { + @State var store = Store(initialState: NavigateAndLoad.State()) { + NavigateAndLoad() + } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + NavigationLink( + "Load optional counter", + isActive: viewStore.binding( + get: \.isNavigationActive, + send: { .setNavigation(isActive: $0) } + ) + ) { + IfLetStore( + self.store.scope(state: \.optionalCounter, action: \.optionalCounter) + ) { + CounterView(store: $0) + } else: { + ProgressView() + } + } + } + } + .navigationTitle("Navigate and load") + } +} + +// MARK: - SwiftUI previews + +struct NavigateAndLoadView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + NavigateAndLoadView( + store: Store(initialState: NavigateAndLoad.State()) { + NavigateAndLoad() + } + ) + } + .navigationViewStyle(.stack) + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift new file mode 100644 index 00000000..26c3a27d --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift @@ -0,0 +1,100 @@ +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. + """ + +// MARK: - Feature domain + +@Reducer +struct LoadThenPresent { + struct State: Equatable { + @PresentationState var counter: Counter.State? + var isActivityIndicatorVisible = false + } + + enum Action { + case counter(PresentationAction) + case counterButtonTapped + case counterPresentationDelayCompleted + } + + @Dependency(\.continuousClock) var clock + + var body: some Reducer { + Reduce { state, action in + switch action { + case .counter: + return .none + + case .counterButtonTapped: + state.isActivityIndicatorVisible = true + return .run { send in + try await self.clock.sleep(for: .seconds(1)) + await send(.counterPresentationDelayCompleted) + } + + case .counterPresentationDelayCompleted: + state.isActivityIndicatorVisible = false + state.counter = Counter.State() + return .none + + } + } + .ifLet(\.$counter, action: \.counter) { + Counter() + } + } +} + +// MARK: - Feature view + +struct LoadThenPresentView: View { + @State var store = Store(initialState: LoadThenPresent.State()) { + LoadThenPresent() + } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + Button { + viewStore.send(.counterButtonTapped) + } label: { + HStack { + Text("Load optional counter") + if viewStore.isActivityIndicatorVisible { + Spacer() + ProgressView() + } + } + } + } + .sheet(store: self.store.scope(state: \.$counter, action: \.counter)) { store in + CounterView(store: store) + } + .navigationTitle("Load and present") + } + } +} + +// MARK: - SwiftUI previews + +struct LoadThenPresentView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + LoadThenPresentView( + store: Store(initialState: LoadThenPresent.State()) { + LoadThenPresent() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift new file mode 100644 index 00000000..c35d715b --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift @@ -0,0 +1,105 @@ +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. + """ + +// MARK: - Feature domain + +@Reducer +struct PresentAndLoad { + struct State: Equatable { + var optionalCounter: Counter.State? + var isSheetPresented = false + } + + enum Action { + case optionalCounter(Counter.Action) + case setSheet(isPresented: Bool) + case setSheetIsPresentedDelayCompleted + } + + @Dependency(\.continuousClock) var clock + private enum CancelID { case load } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .setSheet(isPresented: true): + state.isSheetPresented = true + return .run { send in + try await self.clock.sleep(for: .seconds(1)) + await send(.setSheetIsPresentedDelayCompleted) + } + .cancellable(id: CancelID.load) + + case .setSheet(isPresented: false): + state.isSheetPresented = false + state.optionalCounter = nil + return .cancel(id: CancelID.load) + + case .setSheetIsPresentedDelayCompleted: + state.optionalCounter = Counter.State() + return .none + + case .optionalCounter: + return .none + } + } + .ifLet(\.optionalCounter, action: \.optionalCounter) { + Counter() + } + } +} + +// MARK: - Feature view + +struct PresentAndLoadView: View { + @State var store = Store(initialState: PresentAndLoad.State()) { + PresentAndLoad() + } + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + Button("Load optional counter") { + viewStore.send(.setSheet(isPresented: true)) + } + } + .sheet( + isPresented: viewStore.binding( + get: \.isSheetPresented, + send: { .setSheet(isPresented: $0) } + ) + ) { + IfLetStore(self.store.scope(state: \.optionalCounter, action: \.optionalCounter)) { + CounterView(store: $0) + } else: { + ProgressView() + } + } + .navigationTitle("Present and load") + } + } +} + +// MARK: - SwiftUI previews + +struct PresentAndLoadView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + PresentAndLoadView( + store: Store(initialState: PresentAndLoad.State()) { + PresentAndLoad() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift new file mode 100644 index 00000000..9701d420 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift @@ -0,0 +1,499 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how to use `NavigationStack` with Composable Architecture applications. + """ + +@Reducer +struct NavigationDemo { + struct State: Equatable { + var path = StackState() + } + + enum Action { + case goBackToScreen(id: StackElementID) + case goToABCButtonTapped + case path(StackAction) + case popToRoot + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case let .goBackToScreen(id): + state.path.pop(to: id) + return .none + + case .goToABCButtonTapped: + state.path.append(.screenA()) + state.path.append(.screenB()) + state.path.append(.screenC()) + return .none + + case let .path(action): + switch action { + case .element(id: _, action: .screenB(.screenAButtonTapped)): + state.path.append(.screenA()) + return .none + + case .element(id: _, action: .screenB(.screenBButtonTapped)): + state.path.append(.screenB()) + return .none + + case .element(id: _, action: .screenB(.screenCButtonTapped)): + state.path.append(.screenC()) + return .none + + default: + return .none + } + + case .popToRoot: + state.path.removeAll() + return .none + } + } + .forEach(\.path, action: \.path) { + Path() + } + } + + @Reducer + struct Path { + enum State: Codable, Equatable, Hashable { + case screenA(ScreenA.State = .init()) + case screenB(ScreenB.State = .init()) + case screenC(ScreenC.State = .init()) + } + + enum Action { + case screenA(ScreenA.Action) + case screenB(ScreenB.Action) + case screenC(ScreenC.Action) + } + + var body: some Reducer { + Scope(state: \.screenA, action: \.screenA) { + ScreenA() + } + Scope(state: \.screenB, action: \.screenB) { + ScreenB() + } + Scope(state: \.screenC, action: \.screenC) { + ScreenC() + } + } + } +} + +struct NavigationDemoView: View { + @State var store = Store(initialState: NavigationDemo.State()) { + NavigationDemo() + } + + var body: some View { + NavigationStackStore(self.store.scope(state: \.path, action: \.path)) { + Form { + Section { Text(template: readMe) } + + Section { + NavigationLink( + "Go to screen A", + state: NavigationDemo.Path.State.screenA() + ) + NavigationLink( + "Go to screen B", + state: NavigationDemo.Path.State.screenB() + ) + NavigationLink( + "Go to screen C", + state: NavigationDemo.Path.State.screenC() + ) + } + + Section { + Button("Go to A → B → C") { + self.store.send(.goToABCButtonTapped) + } + } + } + .navigationTitle("Root") + } destination: { + switch $0 { + case .screenA: + CaseLet( + \NavigationDemo.Path.State.screenA, + action: NavigationDemo.Path.Action.screenA, + then: ScreenAView.init(store:) + ) + case .screenB: + CaseLet( + \NavigationDemo.Path.State.screenB, + action: NavigationDemo.Path.Action.screenB, + then: ScreenBView.init(store:) + ) + case .screenC: + CaseLet( + \NavigationDemo.Path.State.screenC, + action: NavigationDemo.Path.Action.screenC, + then: ScreenCView.init(store:) + ) + } + } + .safeAreaInset(edge: .bottom) { + FloatingMenuView(store: self.store) + } + .navigationTitle("Navigation Stack") + } +} + +// MARK: - Floating menu + +struct FloatingMenuView: View { + let store: StoreOf + + struct ViewState: Equatable { + struct Screen: Equatable, Identifiable { + let id: StackElementID + let name: String + } + + var currentStack: [Screen] + var total: Int + init(state: NavigationDemo.State) { + self.total = 0 + self.currentStack = [] + for (id, element) in zip(state.path.ids, state.path) { + switch element { + case let .screenA(screenAState): + self.total += screenAState.count + self.currentStack.insert(Screen(id: id, name: "Screen A"), at: 0) + case .screenB: + self.currentStack.insert(Screen(id: id, name: "Screen B"), at: 0) + case let .screenC(screenBState): + self.total += screenBState.count + self.currentStack.insert(Screen(id: id, name: "Screen C"), at: 0) + } + } + } + } + + var body: some View { + WithViewStore(self.store, observe: ViewState.init) { viewStore in + if viewStore.currentStack.count > 0 { + VStack(alignment: .center) { + Text("Total count: \(viewStore.total)") + Button("Pop to root") { + viewStore.send(.popToRoot, animation: .default) + } + Menu("Current stack") { + ForEach(viewStore.currentStack) { screen in + Button("\(String(describing: screen.id))) \(screen.name)") { + viewStore.send(.goBackToScreen(id: screen.id)) + } + .disabled(screen == viewStore.currentStack.first) + } + Button("Root") { + viewStore.send(.popToRoot, animation: .default) + } + } + } + .padding() + .background(Color(.systemBackground)) + .padding(.bottom, 1) + .transition(.opacity.animation(.default)) + .clipped() + .shadow(color: .black.opacity(0.2), radius: 5, y: 5) + } + } + } +} + +// MARK: - Screen A + +@Reducer +struct ScreenA { + struct State: Codable, Equatable, Hashable { + var count = 0 + var fact: String? + var isLoading = false + } + + enum Action { + case decrementButtonTapped + case dismissButtonTapped + case incrementButtonTapped + case factButtonTapped + case factResponse(Result) + } + + @Dependency(\.dismiss) var dismiss + @Dependency(\.factClient) var factClient + + var body: some Reducer { + Reduce { state, action in + switch action { + case .decrementButtonTapped: + state.count -= 1 + return .none + + case .dismissButtonTapped: + return .run { _ in + await self.dismiss() + } + + case .incrementButtonTapped: + state.count += 1 + return .none + + case .factButtonTapped: + state.isLoading = true + return .run { [count = state.count] send in + await send(.factResponse(Result { try await self.factClient.fetch(count) })) + } + + case let .factResponse(.success(fact)): + state.isLoading = false + state.fact = fact + return .none + + case .factResponse(.failure): + state.isLoading = false + state.fact = nil + return .none + } + } + } +} + +struct ScreenAView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Text( + """ + This screen demonstrates a basic feature hosted in a navigation stack. + + You can also have the child feature dismiss itself, which will communicate back to the \ + root stack view to pop the feature off the stack. + """ + ) + + Section { + HStack { + Text("\(viewStore.count)") + Spacer() + Button { + viewStore.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } + Button { + viewStore.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } + } + .buttonStyle(.borderless) + + Button { + viewStore.send(.factButtonTapped) + } label: { + HStack { + Text("Get fact") + if viewStore.isLoading { + Spacer() + ProgressView() + } + } + } + + if let fact = viewStore.fact { + Text(fact) + } + } + + Section { + Button("Dismiss") { + viewStore.send(.dismissButtonTapped) + } + } + + Section { + NavigationLink( + "Go to screen A", + state: NavigationDemo.Path.State.screenA(.init(count: viewStore.count)) + ) + NavigationLink( + "Go to screen B", + state: NavigationDemo.Path.State.screenB() + ) + NavigationLink( + "Go to screen C", + state: NavigationDemo.Path.State.screenC(.init(count: viewStore.count)) + ) + } + } + } + .navigationTitle("Screen A") + } +} + +// MARK: - Screen B + +@Reducer +struct ScreenB { + struct State: Codable, Equatable, Hashable {} + + enum Action { + case screenAButtonTapped + case screenBButtonTapped + case screenCButtonTapped + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .screenAButtonTapped: + return .none + case .screenBButtonTapped: + return .none + case .screenCButtonTapped: + return .none + } + } + } +} + +struct ScreenBView: View { + let store: StoreOf + + var body: some View { + Form { + Section { + Text( + """ + This screen demonstrates how to navigate to other screens without needing to compile \ + any symbols from those screens. You can send an action into the system, and allow the \ + root feature to intercept that action and push the next feature onto the stack. + """ + ) + } + Button("Decoupled navigation to screen A") { + self.store.send(.screenAButtonTapped) + } + Button("Decoupled navigation to screen B") { + self.store.send(.screenBButtonTapped) + } + Button("Decoupled navigation to screen C") { + self.store.send(.screenCButtonTapped) + } + } + .navigationTitle("Screen B") + } +} + +// MARK: - Screen C + +@Reducer +struct ScreenC { + struct State: Codable, Equatable, Hashable { + var count = 0 + var isTimerRunning = false + } + + enum Action { + case startButtonTapped + case stopButtonTapped + case timerTick + } + + @Dependency(\.mainQueue) var mainQueue + enum CancelID { case timer } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .startButtonTapped: + state.isTimerRunning = true + return .run { send in + for await _ in self.mainQueue.timer(interval: 1) { + await send(.timerTick) + } + } + .cancellable(id: CancelID.timer) + .concatenate(with: .send(.stopButtonTapped)) + + case .stopButtonTapped: + state.isTimerRunning = false + return .cancel(id: CancelID.timer) + + case .timerTick: + state.count += 1 + return .none + } + } + } +} + +struct ScreenCView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Form { + Text( + """ + This screen demonstrates that if you start a long-living effects in a stack, then it \ + will automatically be torn down when the screen is dismissed. + """ + ) + Section { + Text("\(viewStore.count)") + if viewStore.isTimerRunning { + Button("Stop timer") { viewStore.send(.stopButtonTapped) } + } else { + Button("Start timer") { viewStore.send(.startButtonTapped) } + } + } + + Section { + NavigationLink( + "Go to screen A", + state: NavigationDemo.Path.State.screenA(ScreenA.State(count: viewStore.count)) + ) + NavigationLink( + "Go to screen B", + state: NavigationDemo.Path.State.screenB() + ) + NavigationLink( + "Go to screen C", + state: NavigationDemo.Path.State.screenC() + ) + } + } + .navigationTitle("Screen C") + } + } +} + +// MARK: - Previews + +struct NavigationStack_Previews: PreviewProvider { + static var previews: some View { + NavigationDemoView( + store: Store( + initialState: NavigationDemo.State( + path: StackState([ + .screenA(ScreenA.State()) + ]) + ) + ) { + NavigationDemo() + } + ) + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift new file mode 100644 index 00000000..6c42b6c4 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift @@ -0,0 +1,132 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how `Reducer` bodies can recursively nest themselves. + + Tap "Add row" to add a row to the current screen's list. Tap the left-hand side of a row to edit \ + its name, or tap the right-hand side of a row to navigate to its own associated list of rows. + """ + +// MARK: - Feature domain + +@Reducer +struct Nested { + struct State: Equatable, Identifiable { + let id: UUID + var name: String = "" + var rows: IdentifiedArrayOf = [] + } + + enum Action { + case addRowButtonTapped + case nameTextFieldChanged(String) + case onDelete(IndexSet) + indirect case rows(IdentifiedActionOf) + } + + @Dependency(\.uuid) var uuid + + var body: some Reducer { + Reduce { state, action in + switch action { + case .addRowButtonTapped: + state.rows.append(State(id: self.uuid())) + return .none + + case let .nameTextFieldChanged(name): + state.name = name + return .none + + case let .onDelete(indexSet): + state.rows.remove(atOffsets: indexSet) + return .none + + case .rows: + return .none + } + } + .forEach(\.rows, action: \.rows) { + Self() + } + } +} + +// MARK: - Feature view + +struct NestedView: View { + @State var store = Store(initialState: Nested.State(id: UUID())) { + Nested() + } + + var body: some View { + WithViewStore(self.store, observe: \.name) { viewStore in + Form { + Section { + AboutView(readMe: readMe) + } + + ForEachStore(self.store.scope(state: \.rows, action: \.rows)) { rowStore in + WithViewStore(rowStore, observe: \.name) { rowViewStore in + NavigationLink( + destination: NestedView(store: rowStore) + ) { + HStack { + TextField( + "Untitled", + text: rowViewStore.binding(send: { .nameTextFieldChanged($0) }) + ) + Text("Next") + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + } + .onDelete { viewStore.send(.onDelete($0)) } + } + .navigationTitle(viewStore.state.isEmpty ? "Untitled" : viewStore.state) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Add row") { viewStore.send(.addRowButtonTapped) } + } + } + } + } +} + +// MARK: - SwiftUI previews + +struct NestedView_Previews: PreviewProvider { + static var previews: some View { + let initialState = Nested.State( + id: UUID(), + name: "Foo", + rows: [ + Nested.State( + id: UUID(), + name: "Bar", + rows: [ + Nested.State(id: UUID(), name: "", rows: []) + ] + ), + Nested.State( + id: UUID(), + name: "Baz", + rows: [ + Nested.State(id: UUID(), name: "Fizz", rows: []), + Nested.State(id: UUID(), name: "Buzz", rows: []), + ] + ), + Nested.State(id: UUID(), name: "", rows: []), + ] + ) + NavigationView { + NestedView( + store: Store(initialState: initialState) { + Nested() + } + ) + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift new file mode 100644 index 00000000..2e0ad297 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift @@ -0,0 +1,53 @@ +import ComposableArchitecture +import Foundation +import XCTestDynamicOverlay + +struct DownloadClient { + var download: @Sendable (URL) -> AsyncThrowingStream + + @CasePathable + enum Event: Equatable { + case response(Data) + case updateProgress(Double) + } +} + +extension DependencyValues { + var downloadClient: DownloadClient { + get { self[DownloadClient.self] } + set { self[DownloadClient.self] = newValue } + } +} + +extension DownloadClient: DependencyKey { + static let liveValue = Self( + download: { url in + .init { continuation in + Task { + do { + let (bytes, response) = try await URLSession.shared.bytes(from: url) + var data = Data() + var progress = 0 + for try await byte in bytes { + data.append(byte) + let newProgress = Int( + Double(data.count) / Double(response.expectedContentLength) * 100) + if newProgress != progress { + progress = newProgress + continuation.yield(.updateProgress(Double(progress) / 100)) + } + } + continuation.yield(.response(data)) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + ) + + static let testValue = Self( + download: unimplemented("\(Self.self).download") + ) +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift new file mode 100644 index 00000000..6bf8019c --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift @@ -0,0 +1,175 @@ +import ComposableArchitecture +import SwiftUI + +@Reducer +struct DownloadComponent { + struct State: Equatable { + @PresentationState var alert: AlertState? + let id: AnyHashable + var mode: Mode + let url: URL + } + + enum Action { + case alert(PresentationAction) + case buttonTapped + case downloadClient(Result) + + enum Alert { + case deleteButtonTapped + case stopButtonTapped + } + } + + @Dependency(\.downloadClient) var downloadClient + + var body: some Reducer { + Reduce { state, action in + switch action { + case .alert(.presented(.deleteButtonTapped)): + state.alert = nil + state.mode = .notDownloaded + return .none + + case .alert(.presented(.stopButtonTapped)): + state.mode = .notDownloaded + state.alert = nil + return .cancel(id: state.id) + + case .alert: + return .none + + 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 .run { [url = state.url] send in + for try await event in self.downloadClient.download(url) { + await send(.downloadClient(.success(event)), animation: .default) + } + } catch: { error, send in + await send(.downloadClient(.failure(error)), animation: .default) + } + .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 + } + } + .ifLet(\.$alert, action: \.alert) + } + + private var deleteAlert: AlertState { + AlertState { + TextState("Do you want to delete this map from your offline storage?") + } actions: { + ButtonState(role: .destructive, action: .send(.deleteButtonTapped, animation: .default)) { + TextState("Delete") + } + self.nevermindButton + } + } + + private var stopAlert: AlertState { + AlertState { + TextState("Do you want to stop downloading this map?") + } actions: { + ButtonState(role: .destructive, action: .send(.stopButtonTapped, animation: .default)) { + TextState("Stop") + } + self.nevermindButton + } + } + + private var nevermindButton: ButtonState { + ButtonState(role: .cancel) { + TextState("Nevermind") + } + } +} + +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 + } + } +} + +struct DownloadComponentView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Button { + viewStore.send(.buttonTapped) + } label: { + if viewStore.mode == .downloaded { + Image(systemName: "checkmark.circle") + .tint(.accentColor) + } else if viewStore.mode.progress > 0 { + ZStack { + CircularProgressView(value: viewStore.mode.progress) + .frame(width: 16, height: 16) + Rectangle() + .frame(width: 6, height: 6) + } + } else if viewStore.mode == .notDownloaded { + Image(systemName: "icloud.and.arrow.down") + } else if viewStore.mode == .startingToDownload { + ZStack { + ProgressView() + Rectangle() + .frame(width: 6, height: 6) + } + } + } + .foregroundStyle(.primary) + .alert(store: self.store.scope(state: \.$alert, action: \.alert)) + } + } +} + +struct DownloadComponent_Previews: PreviewProvider { + static var previews: some View { + DownloadList_Previews.previews + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift new file mode 100644 index 00000000..19ad3470 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift @@ -0,0 +1,272 @@ +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. + """ + +@Reducer +struct CityMap { + struct State: Equatable, Identifiable { + var download: Download + var downloadAlert: AlertState? + var downloadMode: Mode + + var id: UUID { self.download.id } + + var downloadComponent: DownloadComponent.State { + get { + DownloadComponent.State( + alert: self.downloadAlert, + id: self.download.id, + mode: self.downloadMode, + url: self.download.downloadVideoUrl + ) + } + set { + self.downloadAlert = newValue.alert + self.downloadMode = newValue.mode + } + } + + struct Download: Equatable, Identifiable { + var blurb: String + var downloadVideoUrl: URL + let id: UUID + var title: String + } + } + + enum Action { + case downloadComponent(DownloadComponent.Action) + } + + struct CityMapEnvironment { + var downloadClient: DownloadClient + } + + var body: some Reducer { + Scope(state: \.downloadComponent, action: \.downloadComponent) { + DownloadComponent() + } + + Reduce { state, action in + switch action { + case .downloadComponent(.downloadClient(.success(.response))): + // NB: This is where you could perform the effect to save the data to a file on disk. + return .none + + case .downloadComponent(.alert(.presented(.deleteButtonTapped))): + // NB: This is where you could perform the effect to delete the data from disk. + return .none + + case .downloadComponent: + return .none + } + } + } +} + +struct CityMapRowView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + HStack { + NavigationLink( + destination: CityMapDetailView(store: self.store) + ) { + HStack { + Image(systemName: "map") + Text(viewStore.download.title) + } + .layoutPriority(1) + + Spacer() + + DownloadComponentView( + store: self.store.scope(state: \.downloadComponent, action: \.downloadComponent) + ) + .padding(.trailing, 8) + } + } + } + } +} + +struct CityMapDetailView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + VStack(spacing: 32) { + Text(viewStore.download.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() + + DownloadComponentView( + store: self.store.scope(state: \.downloadComponent, action: \.downloadComponent) + ) + } + + Spacer() + } + .navigationTitle(viewStore.download.title) + .padding() + } + } +} + +@Reducer +struct MapApp { + struct State: Equatable { + var cityMaps: IdentifiedArrayOf + } + + enum Action { + case cityMaps(IdentifiedActionOf) + } + + var body: some Reducer { + EmptyReducer().forEach(\.cityMaps, action: \.cityMaps) { + CityMap() + } + } +} + +struct CitiesView: View { + @State var store = Store(initialState: MapApp.State(cityMaps: .mocks)) { + MapApp() + } + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + ForEachStore(self.store.scope(state: \.cityMaps, action: \.cityMaps)) { cityMapStore in + CityMapRowView(store: cityMapStore) + .buttonStyle(.borderless) + } + } + .navigationTitle("Offline Downloads") + } +} + +struct DownloadList_Previews: PreviewProvider { + static var previews: some View { + Group { + NavigationView { + CitiesView( + store: Store(initialState: MapApp.State(cityMaps: .mocks)) { + MapApp() + } + ) + } + + NavigationView { + CityMapDetailView( + store: Store(initialState: IdentifiedArrayOf.mocks.first!) {} + ) + } + } + } +} + +extension IdentifiedArray where ID == CityMap.State.ID, Element == CityMap.State { + static let mocks: Self = [ + CityMap.State( + download: CityMap.State.Download( + 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" + ), + downloadMode: .notDownloaded + ), + CityMap.State( + download: CityMap.State.Download( + 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" + ), + downloadMode: .notDownloaded + ), + CityMap.State( + download: CityMap.State.Download( + 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" + ), + downloadMode: .notDownloaded + ), + CityMap.State( + download: CityMap.State.Download( + 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" + ), + downloadMode: .notDownloaded + ), + CityMap.State( + download: CityMap.State.Download( + 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" + ), + downloadMode: .notDownloaded + ), + ] +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift new file mode 100644 index 00000000..751be3a7 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift @@ -0,0 +1,219 @@ +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 `Favoriting` 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: - Reusable favorite component + +struct FavoritingState: Equatable { + @PresentationState var alert: AlertState? + let id: ID + var isFavorite: Bool +} + +@CasePathable +enum FavoritingAction { + case alert(PresentationAction) + case buttonTapped + case response(Result) + + enum Alert: Equatable {} +} + +@Reducer +struct Favoriting { + let favorite: @Sendable (ID, Bool) async throws -> Bool + + private struct CancelID: Hashable { + let id: AnyHashable + } + + var body: some Reducer, FavoritingAction> { + Reduce { state, action in + switch action { + case .alert(.dismiss): + state.alert = nil + state.isFavorite.toggle() + return .none + + case .buttonTapped: + state.isFavorite.toggle() + + return .run { [id = state.id, isFavorite = state.isFavorite, favorite] send in + await send(.response(Result { try await favorite(id, isFavorite) })) + } + .cancellable(id: CancelID(id: state.id), cancelInFlight: true) + + case let .response(.failure(error)): + state.alert = AlertState { TextState(error.localizedDescription) } + return .none + + case let .response(.success(isFavorite)): + state.isFavorite = isFavorite + return .none + } + } + } +} + +struct FavoriteButton: View { + let store: Store, FavoritingAction> + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + Button { + viewStore.send(.buttonTapped) + } label: { + Image(systemName: "heart") + .symbolVariant(viewStore.isFavorite ? .fill : .none) + } + .alert(store: self.store.scope(state: \.$alert, action: \.alert)) + } + } +} + +// MARK: - Feature domain + +@Reducer +struct Episode { + struct State: Equatable, Identifiable { + var alert: AlertState? + let id: UUID + var isFavorite: Bool + let title: String + + var favorite: FavoritingState { + get { .init(alert: self.alert, id: self.id, isFavorite: self.isFavorite) } + set { (self.alert, self.isFavorite) = (newValue.alert, newValue.isFavorite) } + } + } + + enum Action { + case favorite(FavoritingAction) + } + + let favorite: @Sendable (UUID, Bool) async throws -> Bool + + var body: some Reducer { + Scope(state: \.favorite, action: \.favorite) { + Favoriting(favorite: self.favorite) + } + } +} + +// MARK: - Feature view + +struct EpisodeView: View { + let store: StoreOf + + var body: some View { + WithViewStore(self.store, observe: { $0 }) { viewStore in + HStack(alignment: .firstTextBaseline) { + Text(viewStore.title) + + Spacer() + + FavoriteButton(store: self.store.scope(state: \.favorite, action: \.favorite)) + } + } + } +} + +@Reducer +struct Episodes { + struct State: Equatable { + var episodes: IdentifiedArrayOf = [] + } + + enum Action { + case episodes(IdentifiedActionOf) + } + + let favorite: @Sendable (UUID, Bool) async throws -> Bool + + var body: some Reducer { + Reduce { state, action in + .none + } + .forEach(\.episodes, action: \.episodes) { + Episode(favorite: self.favorite) + } + } +} + +struct EpisodesView: View { + @State var store = Store(initialState: Episodes.State()) { + Episodes(favorite: favorite(id:isFavorite:)) + } + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + ForEachStore(self.store.scope(state: \.episodes, action: \.episodes)) { rowStore in + EpisodeView(store: rowStore) + } + .buttonStyle(.borderless) + } + .navigationTitle("Favoriting") + } +} + +// MARK: - SwiftUI previews + +struct EpisodesView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EpisodesView( + store: Store( + initialState: Episodes.State( + episodes: .mocks + ) + ) { + Episodes(favorite: favorite(id:isFavorite:)) + } + ) + } + } +} + +struct FavoriteError: LocalizedError, Equatable { + var errorDescription: String? { + "Favoriting failed." + } +} + +@Sendable func favorite(id: ID, isFavorite: Bool) async throws -> Bool { + try await Task.sleep(for: .seconds(1)) + if .random(in: 0...1) > 0.25 { + return isFavorite + } else { + throw FavoriteError() + } +} + +extension IdentifiedArray where ID == Episode.State.ID, Element == Episode.State { + static let mocks: Self = [ + Episode.State(id: UUID(), isFavorite: false, title: "Functions"), + Episode.State(id: UUID(), isFavorite: false, title: "Side Effects"), + Episode.State(id: UUID(), isFavorite: false, title: "Algebraic Data Types"), + Episode.State(id: UUID(), isFavorite: false, title: "DSLs"), + Episode.State(id: UUID(), isFavorite: false, title: "Parsers"), + Episode.State(id: UUID(), isFavorite: false, title: "Composable Architecture"), + ] +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png b/0262-observable-architecture-pt4/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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png differ diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png b/0262-observable-architecture-pt4/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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png differ diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-iPadPro@2x.png b/0262-observable-architecture-pt4/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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-iPadPro@2x.png differ diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/0262-observable-architecture-pt4/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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..4f45077f --- /dev/null +++ b/0262-observable-architecture-pt4/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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/transparent.png b/0262-observable-architecture-pt4/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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/transparent.png differ diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift new file mode 100644 index 00000000..3fa429ad --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift @@ -0,0 +1,11 @@ +import ComposableArchitecture +import SwiftUI + +@main +struct CaseStudiesApp: App { + var body: some Scene { + WindowGroup { + RootView() + } + } +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift new file mode 100644 index 00000000..95bdb6b8 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift @@ -0,0 +1,34 @@ +import ComposableArchitecture +import Foundation +import XCTestDynamicOverlay + +struct FactClient { + var fetch: @Sendable (Int) async throws -> String +} + +extension DependencyValues { + var factClient: FactClient { + get { self[FactClient.self] } + set { self[FactClient.self] = newValue } + } +} + +extension FactClient: DependencyKey { + /// 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. + static let liveValue = Self( + fetch: { number in + try await Task.sleep(for: .seconds(1)) + let (data, _) = try await URLSession.shared + .data(from: URL(string: "http://numbersapi.com/\(number)/trivia")!) + return String(decoding: data, as: UTF8.self) + } + ) + + /// 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 testValue = Self( + fetch: unimplemented("\(Self.self).fetch") + ) +} diff --git a/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Info.plist b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Info.plist new file mode 100644 index 00000000..b94c796a --- /dev/null +++ b/0262-observable-architecture-pt4/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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/AboutView.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/AboutView.swift new file mode 100644 index 00000000..6ca62459 --- /dev/null +++ b/0262-observable-architecture-pt4/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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift new file mode 100644 index 00000000..0ca6237f --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift @@ -0,0 +1,23 @@ +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)) + .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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/ResignFirstResponder.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/ResignFirstResponder.swift new file mode 100644 index 00000000..fce58e01 --- /dev/null +++ b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/ResignFirstResponder.swift @@ -0,0 +1,21 @@ +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 + @MainActor + 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/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/TemplateText.swift b/0262-observable-architecture-pt4/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/TemplateText.swift new file mode 100644 index 00000000..9e27a7d3 --- /dev/null +++ b/0262-observable-architecture-pt4/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