diff --git a/0272-shared-state-pt5/README.md b/0272-shared-state-pt5/README.md new file mode 100644 index 00000000..0afcf2a8 --- /dev/null +++ b/0272-shared-state-pt5/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [Shared State: Testing, Part 2](https://www.pointfree.co/episodes/ep272-shared-state-testing-part-2) +> +> We will employ `@Shared`’s new testing capabilities in a complex scenario: a sign up flow. We will see how a deeply nested integration of features all sharing the same state can be tested simply, and we will see how we can leverage the same tricks employed by the test store to add debug tools to reducers using shared state. diff --git a/0272-shared-state-pt5/swift-composable-architecture/.github/CODE_OF_CONDUCT.md b/0272-shared-state-pt5/swift-composable-architecture/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..6f9886f0 --- /dev/null +++ b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/.github/ISSUE_TEMPLATE/bug_report.yml b/0272-shared-state-pt5/swift-composable-architecture/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..c1e65d9d --- /dev/null +++ b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/.github/ISSUE_TEMPLATE/config.yml b/0272-shared-state-pt5/swift-composable-architecture/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..33bd9665 --- /dev/null +++ b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/.github/package.xcworkspace/contents.xcworkspacedata b/0272-shared-state-pt5/swift-composable-architecture/.github/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..0fd0bc31 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/.github/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0272-shared-state-pt5/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme b/0272-shared-state-pt5/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme new file mode 100644 index 00000000..318b7853 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme b/0272-shared-state-pt5/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme new file mode 100644 index 00000000..9bcb82da --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/.github/package.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/.github/workflows/ci.yml b/0272-shared-state-pt5/swift-composable-architecture/.github/workflows/ci.yml new file mode 100644 index 00000000..9589ce20 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +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-14 + strategy: + matrix: + config: + - debug + - release + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 15.2 + run: sudo xcode-select -s /Applications/Xcode_15.2.app + - name: Build ${{ matrix.config }} + run: make CONFIG=${{ matrix.config }} build-all-platforms + - 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.2 + run: sudo xcode-select -s /Applications/Xcode_15.2.app + - name: Build for library evolution + run: make build-for-library-evolution + + library-compatibility: + name: Library (Swift 5.7.1) + runs-on: macos-12 + strategy: + matrix: + config: + - debug + - release + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 14.1 + run: sudo xcode-select -s /Applications/Xcode_14.1.app + - name: Build ${{ matrix.config }} + run: swift build -c ${{ matrix.config }} + + benchmarks: + name: Benchmarks + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 15.2 + run: sudo xcode-select -s /Applications/Xcode_15.2.app + - name: Run benchmark + run: make benchmark + + examples: + name: Examples + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 15.2 + run: sudo xcode-select -s /Applications/Xcode_15.2.app + - name: Run tests + run: make test-examples diff --git a/0272-shared-state-pt5/swift-composable-architecture/.github/workflows/documentation.yml b/0272-shared-state-pt5/swift-composable-architecture/.github/workflows/documentation.yml new file mode 100644 index 00000000..139d2e4a --- /dev/null +++ b/0272-shared-state-pt5/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.1 + run: sudo xcode-select -s /Applications/Xcode_15.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/0272-shared-state-pt5/swift-composable-architecture/.github/workflows/format.yml b/0272-shared-state-pt5/swift-composable-architecture/.github/workflows/format.yml new file mode 100644 index 00000000..c04d1353 --- /dev/null +++ b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/.github/workflows/release.yml b/0272-shared-state-pt5/swift-composable-architecture/.github/workflows/release.yml new file mode 100644 index 00000000..23a4e949 --- /dev/null +++ b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/.github/workflows/scheduled-ci.yml b/0272-shared-state-pt5/swift-composable-architecture/.github/workflows/scheduled-ci.yml new file mode 100644 index 00000000..a270386c --- /dev/null +++ b/0272-shared-state-pt5/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.1 + run: sudo xcode-select -s /Applications/Xcode_15.1.app + - name: Run tests + run: make test-integration diff --git a/0272-shared-state-pt5/swift-composable-architecture/.gitignore b/0272-shared-state-pt5/swift-composable-architecture/.gitignore new file mode 100644 index 00000000..ac4e556f --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/.build +/.swiftpm +/Packages +/*.swiftinterface +/*.xcodeproj +xcuserdata/ diff --git a/0272-shared-state-pt5/swift-composable-architecture/.spi.yml b/0272-shared-state-pt5/swift-composable-architecture/.spi.yml new file mode 100644 index 00000000..0a78973c --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/.spi.yml @@ -0,0 +1,12 @@ +version: 1 +builder: + configs: + - platform: ios + scheme: ComposableArchitecture + - platform: macos-xcodebuild + scheme: ComposableArchitecture + - platform: tvos + scheme: ComposableArchitecture + - platform: watchos + scheme: ComposableArchitecture + - documentation_targets: [ComposableArchitecture] diff --git a/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/contents.xcworkspacedata b/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..f562e7d0 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..08de0be8 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme b/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme new file mode 100644 index 00000000..6008215b --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/ComposableArchitecture.xcscheme @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme b/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme new file mode 100644 index 00000000..781d50f3 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/ComposableArchitecture.xcworkspace/xcshareddata/xcschemes/swift-composable-architecture-benchmark.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/0272-shared-state-pt5/swift-composable-architecture/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj new file mode 100644 index 00000000..9d8194c9 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj @@ -0,0 +1,1262 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 2AA49B752B97AAC600F3690E /* SignUpFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AA49B742B97AAC600F3690E /* SignUpFlowTests.swift */; }; + 433B8B762A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433B8B752A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.swift */; }; + 4BF4745D2B7D75F000A388C0 /* SignUpFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BF4745C2B7D75F000A388C0 /* SignUpFlow.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 */; }; + 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 */ + 2AA49B742B97AAC600F3690E /* SignUpFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpFlowTests.swift; sourceTree = ""; }; + 433B8B752A49C9AF0035DEE4 /* 03-Navigation-Multiple-Destinations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Navigation-Multiple-Destinations.swift"; sourceTree = ""; }; + 4BF4745C2B7D75F000A388C0 /* SignUpFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpFlow.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 = ""; }; + 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 */, + ); + 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 */, + 4BF4745C2B7D75F000A388C0 /* SignUpFlow.swift */, + DC89C41A24460F95006900B9 /* 00-RootView.swift */, + CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndConfirmationDialogs.swift */, + DC88D8A5245341EC0077F427 /* 01-GettingStarted-Animations.swift */, + CA25E5D124463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift */, + DC5B505025C86EBC000D8DFD /* 01-GettingStarted-Bindings-Forms.swift */, + DCC68EE02447C4630037F998 /* 01-GettingStarted-Composition-TwoCounters.swift */, + DC89C4432446111B006900B9 /* 01-GettingStarted-Counter.swift */, + CA3E421E26B8337500581ABC /* 01-GettingStarted-FocusState.swift */, + DCC68EDC2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift */, + CA7BC8ED245CCFE4001FB69F /* 01-GettingStarted-SharedState.swift */, + CAA9ADC12446587C0003A984 /* 02-Effects-Basics.swift */, + CAA9ADC524465C810003A984 /* 02-Effects-Cancellation.swift */, + CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */, + CABC4F3826AEE00C00D5FA2C /* 02-Effects-Refreshable.swift */, + DC89C45424465C44006900B9 /* 02-Effects-Timers.swift */, + CA410EDF247A15FE00E41798 /* 02-Effects-WebSocket.swift */, + 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 */, + 2AA49B742B97AAC600F3690E /* SignUpFlowTests.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 = 1520; + 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 = ( + 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 = ( + 4BF4745D2B7D75F000A388C0 /* SignUpFlow.swift in Sources */, + 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 */, + 2AA49B752B97AAC600F3690E /* SignUpFlowTests.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; + }; + 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; + }; + 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"; + }; + 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"; + }; + 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"; + SWIFT_STRICT_CONCURRENCY = minimal; + }; + 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"; + SWIFT_STRICT_CONCURRENCY = minimal; + 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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme new file mode 100644 index 00000000..8687c796 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (SwiftUI).xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme new file mode 100644 index 00000000..239fb4a5 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/CaseStudies (UIKit).xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/tvOSCaseStudies.xcscheme b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/tvOSCaseStudies.xcscheme new file mode 100644 index 00000000..9aa73601 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/CaseStudies.xcodeproj/xcshareddata/xcschemes/tvOSCaseStudies.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/README.md b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/README.md new file mode 100644 index 00000000..0468b9f7 --- /dev/null +++ b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift new file mode 100644 index 00000000..04cf9d45 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift @@ -0,0 +1,197 @@ +import ComposableArchitecture +import SwiftUI + +struct RootView: View { + @State var isNavigationStackCaseStudyPresented = false + + var body: some View { + NavigationStack { + Form { + Section { + NavigationLink("Basics") { + Demo(store: Store(initialState: Counter.State()) { Counter() }) { store in + CounterDemoView(store: store) + } + } + NavigationLink("Combining reducers") { + Demo(store: Store(initialState: TwoCounters.State()) { TwoCounters() }) { store in + TwoCountersView(store: store) + } + } + NavigationLink("Bindings") { + Demo(store: Store(initialState: BindingBasics.State()) { BindingBasics() }) { store in + BindingBasicsView(store: store) + } + } + NavigationLink("Form bindings") { + Demo(store: Store(initialState: BindingForm.State()) { BindingForm() }) { store in + BindingFormView(store: store) + } + } + NavigationLink("Optional state") { + Demo(store: Store(initialState: OptionalBasics.State()) { OptionalBasics() }) { store in + OptionalBasicsView(store: store) + } + } + NavigationLink("Shared state") { + Demo(store: Store(initialState: SharedState.State()) { SharedState() }) { store in + SharedStateView(store: store) + } + } + NavigationLink("Alerts and Confirmation Dialogs") { + Demo( + store: Store(initialState: AlertAndConfirmationDialog.State()) { + AlertAndConfirmationDialog() + } + ) { store in + AlertAndConfirmationDialogView(store: store) + } + } + NavigationLink("Focus State") { + Demo(store: Store(initialState: FocusDemo.State()) { FocusDemo() }) { store in + FocusDemoView(store: store) + } + } + NavigationLink("Animations") { + Demo(store: Store(initialState: Animations.State()) { Animations() }) { store in + AnimationsView(store: store) + } + } + } header: { + Text("Getting started") + } + + Section { + NavigationLink("Basics") { + Demo(store: Store(initialState: EffectsBasics.State()) { EffectsBasics() }) { store in + EffectsBasicsView(store: store) + } + } + NavigationLink("Cancellation") { + Demo( + store: Store(initialState: EffectsCancellation.State()) { EffectsCancellation() } + ) { store in + EffectsCancellationView(store: store) + } + } + NavigationLink("Long-living effects") { + Demo( + store: Store(initialState: LongLivingEffects.State()) { LongLivingEffects() } + ) { store in + LongLivingEffectsView(store: store) + } + } + NavigationLink("Refreshable") { + Demo(store: Store(initialState: Refreshable.State()) { Refreshable() }) { store in + RefreshableView(store: store) + } + } + NavigationLink("Timers") { + Demo(store: Store(initialState: Timers.State()) { Timers() }) { store in + TimersView(store: store) + } + } + NavigationLink("Web socket") { + Demo(store: Store(initialState: WebSocket.State()) { WebSocket() }) { store in + WebSocketView(store: store) + } + } + } header: { + Text("Effects") + } + + Section { + Button("Stack") { + self.isNavigationStackCaseStudyPresented = true + } + .buttonStyle(.plain) + + NavigationLink("Navigate and load data") { + Demo( + store: Store(initialState: NavigateAndLoad.State()) { NavigateAndLoad() } + ) { store in + NavigateAndLoadView(store: store) + } + } + + NavigationLink("Lists: Navigate and load data") { + Demo( + store: Store(initialState: NavigateAndLoadList.State()) { NavigateAndLoadList() } + ) { store in + NavigateAndLoadListView(store: store) + } + } + NavigationLink("Sheets: Present and load data") { + Demo(store: Store(initialState: PresentAndLoad.State()) { PresentAndLoad() }) { store in + PresentAndLoadView(store: store) + } + } + NavigationLink("Sheets: Load data then present") { + Demo( + store: Store(initialState: LoadThenPresent.State()) { LoadThenPresent() } + ) { store in + LoadThenPresentView(store: store) + } + } + NavigationLink("Multiple destinations") { + Demo( + store: Store(initialState: MultipleDestinations.State()) { MultipleDestinations() } + ) { store in + MultipleDestinationsView(store: store) + } + } + } header: { + Text("Navigation") + } + + Section { + NavigationLink("Reusable favoriting component") { + Demo(store: Store(initialState: Episodes.State()) { Episodes() }) { store in + EpisodesView(store: store) + } + } + NavigationLink("Reusable offline download component") { + Demo(store: Store(initialState: MapApp.State()) { MapApp() }) { store in + CitiesView(store: store) + } + } + NavigationLink("Recursive state and actions") { + Demo(store: Store(initialState: Nested.State()) { Nested() }) { store in + NestedView(store: store) + } + } + } header: { + Text("Higher-order reducers") + } + } + .navigationTitle("Case Studies") + .sheet(isPresented: self.$isNavigationStackCaseStudyPresented) { + Demo(store: Store(initialState: NavigationDemo.State()) { NavigationDemo() }) { store in + NavigationDemoView(store: store) + } + } + } + } +} + +/// This wrapper provides an "entry" point into an individual demo that can own a store. +struct Demo: View { + @SwiftUI.State var store: Store + let content: (Store) -> Content + + init( + store: Store, + @ViewBuilder content: @escaping (Store) -> Content + ) { + self.store = store + self.content = content + } + + var body: some View { + self.content(self.store) + } +} + +#Preview { + RootView() +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift new file mode 100644 index 00000000..19db2136 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-AlertsAndConfirmationDialogs.swift @@ -0,0 +1,129 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This demonstrates how to best handle alerts and confirmation dialogs in the Composable \ + Architecture. + + The library comes with two types, `AlertState` and `ConfirmationDialogState`, which are data \ + descriptions of the state and actions of an alert or dialog. These types can be constructed in \ + reducers to control whether or not an alert or confirmation dialog is displayed, and \ + corresponding view modifiers, `alert(_:)` and `confirmationDialog(_:)`, can be handed bindings \ + to a store focused on an alert or dialog domain so that the alert or dialog can be displayed in \ + the view. + + The benefit of using these types is that you can get full test coverage on how a user interacts \ + with alerts and dialogs in your application + """ + +@Reducer +struct AlertAndConfirmationDialog { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + @Presents 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) + } +} + +struct AlertAndConfirmationDialogView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + Text("Count: \(store.count)") + Button("Alert") { store.send(.alertButtonTapped) } + Button("Confirmation Dialog") { store.send(.confirmationDialogButtonTapped) } + } + .navigationTitle("Alerts & Dialogs") + .alert($store.scope(state: \.alert, action: \.alert)) + .confirmationDialog($store.scope(state: \.confirmationDialog, action: \.confirmationDialog)) + } +} + +#Preview { + NavigationStack { + AlertAndConfirmationDialogView( + store: Store(initialState: AlertAndConfirmationDialog.State()) { + AlertAndConfirmationDialog() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift new file mode 100644 index 00000000..b428f28d --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift @@ -0,0 +1,168 @@ +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 also pass \ + along an explicit animation, or you can call `store.send` in a `withAnimation` block. + + To animate changes made to state through a binding, you can call the `animation` method on \ + `Binding`. + + To animate asynchronous changes made to state via effects, use the `Effect.run` style of \ + effects, which allows you to send actions with animations. + + Try out the demo by tapping or dragging anywhere on the screen to move the dot, and by flipping \ + the toggle at the bottom of the screen. + """ + +@Reducer +struct Animations { + @ObservableState + struct State: Equatable { + @Presents 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) + } +} + +struct AnimationsView: View { + @Bindable var store: StoreOf + + var body: some View { + VStack(alignment: .leading) { + Text(template: readMe, .body) + .padding() + .gesture( + DragGesture(minimumDistance: 0).onChanged { gesture in + store.send( + .tapped(gesture.location), + animation: .interactiveSpring(response: 0.25, dampingFraction: 0.1) + ) + } + ) + .overlay { + GeometryReader { proxy in + Circle() + .fill(store.circleColor) + .colorInvert() + .blendMode(.difference) + .frame(width: 50, height: 50) + .scaleEffect(store.isCircleScaled ? 2 : 1) + .position( + x: store.circleCenter?.x ?? proxy.size.width / 2, + y: store.circleCenter?.y ?? proxy.size.height / 2 + ) + .offset(y: store.circleCenter == nil ? 0 : -44) + } + .allowsHitTesting(false) + } + Toggle( + "Big mode", + isOn: + $store.isCircleScaled.sending(\.circleScaleToggleChanged) + .animation(.interactiveSpring(response: 0.25, dampingFraction: 0.1)) + ) + .padding() + Button("Rainbow") { store.send(.rainbowButtonTapped, animation: .linear) } + .padding([.horizontal, .bottom]) + Button("Reset") { store.send(.resetButtonTapped) } + .padding([.horizontal, .bottom]) + } + .alert($store.scope(state: \.alert, action: \.alert)) + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationStack { + AnimationsView( + store: Store(initialState: Animations.State()) { + Animations() + } + ) + } +} + +#Preview("Dark mode") { + NavigationStack { + AnimationsView( + store: Store(initialState: Animations.State()) { + Animations() + } + ) + } + .environment(\.colorScheme, .dark) +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift new file mode 100644 index 00000000..64228127 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift @@ -0,0 +1,126 @@ +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 a store by taking a bindable store, chaining into a \ + property of state that renders the component, and calling the `sending` method with a key path \ + to an action to send when the component changes, which means you can keep using a unidirectional \ + style for your feature. + """ + +@Reducer +struct BindingBasics { + @ObservableState + 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 + } + } + } +} + +struct BindingBasicsView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + HStack { + TextField("Type here", text: $store.text.sending(\.textChanged)) + .disableAutocorrection(true) + .foregroundStyle(store.toggleIsOn ? Color.secondary : .primary) + Text(alternate(store.text)) + } + .disabled(store.toggleIsOn) + + Toggle( + "Disable other controls", + isOn: $store.toggleIsOn.sending(\.toggleChanged).resignFirstResponder() + ) + + Stepper( + "Max slider value: \(store.stepCount)", + value: $store.stepCount.sending(\.stepCountChanged), + in: 0...100 + ) + .disabled(store.toggleIsOn) + + HStack { + Text("Slider value: \(Int(store.sliderValue))") + Slider( + value: $store.sliderValue.sending(\.sliderValueChanged), + in: 0...Double(store.stepCount) + ) + .tint(.accentColor) + } + .disabled(store.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() +} + +#Preview { + NavigationStack { + BindingBasicsView( + store: Store(initialState: BindingBasics.State()) { + BindingBasics() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift new file mode 100644 index 00000000..5a087dcb --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift @@ -0,0 +1,112 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This file demonstrates how to handle two-way bindings in the Composable Architecture using \ + bindable actions and binding reducers. + + Bindable 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, which the `BindingReducer` can automatically apply to state. + + It is instructive to compare this case study to the "Binding Basics" case study. + """ + +@Reducer +struct BindingForm { + @ObservableState + struct State: Equatable { + var sliderValue = 5.0 + var stepCount = 10 + var text = "" + 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 + } + } + } +} + +struct BindingFormView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + HStack { + TextField("Type here", text: $store.text) + .disableAutocorrection(true) + .foregroundStyle(store.toggleIsOn ? Color.secondary : .primary) + Text(alternate(store.text)) + } + .disabled(store.toggleIsOn) + + Toggle("Disable other controls", isOn: $store.toggleIsOn.resignFirstResponder()) + + Stepper( + "Max slider value: \(store.stepCount)", + value: $store.stepCount, + in: 0...100 + ) + .disabled(store.toggleIsOn) + + HStack { + Text("Slider value: \(Int(store.sliderValue))") + + Slider(value: $store.sliderValue, in: 0...Double(store.stepCount)) + .tint(.accentColor) + } + .disabled(store.toggleIsOn) + + Button("Reset") { + store.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() +} + +#Preview { + NavigationStack { + BindingFormView( + store: Store(initialState: BindingForm.State()) { + BindingForm() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift new file mode 100644 index 00000000..acc69491 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift @@ -0,0 +1,68 @@ +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. + """ + +@Reducer +struct TwoCounters { + @ObservableState + struct State: Equatable { + var counter1 = Counter.State() + var counter2 = Counter.State() + } + + enum Action { + case counter1(Counter.Action) + case counter2(Counter.Action) + } + + var body: some Reducer { + Scope(state: \.counter1, action: \.counter1) { + Counter() + } + Scope(state: \.counter2, action: \.counter2) { + Counter() + } + } +} + +struct TwoCountersView: View { + let store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + HStack { + Text("Counter 1") + Spacer() + CounterView(store: store.scope(state: \.counter1, action: \.counter1)) + } + + HStack { + Text("Counter 2") + Spacer() + CounterView(store: store.scope(state: \.counter2, action: \.counter2)) + } + } + .buttonStyle(.borderless) + .navigationTitle("Two counters demo") + } +} + +#Preview { + NavigationStack { + TwoCountersView( + store: Store(initialState: TwoCounters.State()) { + TwoCounters() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift new file mode 100644 index 00000000..f94d4f64 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift @@ -0,0 +1,88 @@ +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. + """ + +@Reducer +struct Counter { + @ObservableState + struct State: Equatable { + var count = 0 + } + + enum Action { + case decrementButtonTapped + case incrementButtonTapped + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .decrementButtonTapped: + state.count -= 1 + return .none + case .incrementButtonTapped: + state.count += 1 + return .none + } + } + } +} + +struct CounterView: View { + let store: StoreOf + + var body: some View { + HStack { + Button { + store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } + + Text("\(store.count)") + .monospacedDigit() + + Button { + store.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } + } + } +} + +struct CounterDemoView: View { + let store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + Section { + CounterView(store: store) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderless) + .navigationTitle("Counter demo") + } +} + +#Preview { + NavigationStack { + CounterDemoView( + store: Store(initialState: Counter.State()) { + Counter() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift new file mode 100644 index 00000000..3338701b --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift @@ -0,0 +1,81 @@ +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 the first empty field. + """ + +@Reducer +struct FocusDemo { + @ObservableState + struct State: Equatable { + var focusedField: Field? + var password: String = "" + 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 + } + } + } +} + +struct FocusDemoView: View { + @Bindable var store: StoreOf + @FocusState var focusedField: FocusDemo.State.Field? + + var body: some View { + Form { + AboutView(readMe: readMe) + + VStack { + TextField("Username", text: $store.username) + .focused($focusedField, equals: .username) + SecureField("Password", text: $store.password) + .focused($focusedField, equals: .password) + Button("Sign In") { + store.send(.signInButtonTapped) + } + .buttonStyle(.borderedProminent) + } + .textFieldStyle(.roundedBorder) + } + // Synchronize store focus state and local focus state. + .bind($store.focusedField, to: $focusedField) + .navigationTitle("Focus demo") + } +} + +#Preview { + NavigationStack { + FocusDemoView( + store: Store(initialState: FocusDemo.State()) { + FocusDemo() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift new file mode 100644 index 00000000..c2b8c778 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-OptionalState.swift @@ -0,0 +1,96 @@ +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. + """ + +@Reducer +struct OptionalBasics { + @ObservableState + struct State: Equatable { + var optionalCounter: Counter.State? + } + + enum Action { + case optionalCounter(Counter.Action) + case toggleCounterButtonTapped + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .toggleCounterButtonTapped: + state.optionalCounter = + state.optionalCounter == nil + ? Counter.State() + : nil + return .none + case .optionalCounter: + return .none + } + } + .ifLet(\.optionalCounter, action: \.optionalCounter) { + Counter() + } + } +} + +struct OptionalBasicsView: View { + let store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + Button("Toggle counter state") { + store.send(.toggleCounterButtonTapped) + } + + if let store = 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`") + } + } + .navigationTitle("Optional state") + } +} + +#Preview { + NavigationStack { + OptionalBasicsView( + store: Store(initialState: OptionalBasics.State()) { + OptionalBasics() + } + ) + } +} + +#Preview("Deep-linked") { + NavigationStack { + OptionalBasicsView( + store: Store( + initialState: OptionalBasics.State( + optionalCounter: Counter.State( + count: 42 + ) + ) + ) { + OptionalBasics() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift new file mode 100644 index 00000000..1999537c --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift @@ -0,0 +1,275 @@ +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. + """ + +@Reducer +struct CounterTab { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + @Shared var stats: Stats + } + + 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.stats.decrement() + return .none + + case .incrementButtonTapped: + state.stats.increment() + return .none + + case .isPrimeButtonTapped: + state.alert = AlertState { + TextState( + isPrime(state.stats.count) + ? "👍 The number \(state.stats.count) is prime!" + : "👎 The number \(state.stats.count) is not prime :(" + ) + } + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } +} + +struct CounterTabView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Text(template: readMe, .caption) + + VStack(spacing: 16) { + HStack { + Button { + store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } + + Text("\(store.stats.count)") + .monospacedDigit() + + Button { + store.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } + } + + Button("Is this prime?") { store.send(.isPrimeButtonTapped) } + } + } + .buttonStyle(.borderless) + .navigationTitle("Shared State Demo") + .alert($store.scope(state: \.alert, action: \.alert)) + } +} + +@Reducer +struct ProfileTab { + @ObservableState + struct State: Equatable { + @Shared var stats: Stats + } + + enum Action { + case resetStatsButtonTapped + } + + var body: some Reducer { + Reduce { state, action in + switch action { + case .resetStatsButtonTapped: + state.stats.reset() + return .none + } + } + } +} + +struct ProfileTabView: View { + let store: StoreOf + + var body: some View { + Form { + 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: \(store.stats.count)") + Text("Max count: \(store.stats.maxCount)") + Text("Min count: \(store.stats.minCount)") + Text("Total number of count events: \(store.stats.numberOfCounts)") + Button("Reset") { store.send(.resetStatsButtonTapped) } + } + } + .buttonStyle(.borderless) + .navigationTitle("Profile") + } +} + +@Reducer +struct SharedState { + enum Tab { case counter, profile } + + @ObservableState + struct State: Equatable { + var currentTab = Tab.counter + var counter: CounterTab.State + var profile: ProfileTab.State + @Shared var stats: Stats + init( + currentTab: Tab = Tab.counter, + stats sharedStats: Shared = Shared(Stats()) + ) { + self.currentTab = currentTab + self.counter = CounterTab.State(stats: sharedStats) + self.profile = ProfileTab.State(stats: sharedStats) + self._stats = sharedStats + } + } + + enum Action { + case counter(CounterTab.Action) + case profile(ProfileTab.Action) + case selectTab(Tab) + } + + var body: some Reducer { + Scope(state: \.counter, action: \.counter) { + CounterTab() + } +// .onChange(of: \.counter.stats) { _, stats in +// Reduce { state, _ in +// state.profile.stats = stats +// return .none +// } +// } + + Scope(state: \.profile, action: \.profile) { + ProfileTab() + } +// .onChange(of: \.profile.stats) { _, stats in +// Reduce { state, _ in +// state.counter.stats = stats +// return .none +// } +// } + + Reduce { state, action in + switch action { + case .counter, .profile: + return .none + case let .selectTab(tab): + state.currentTab = tab + state.stats.increment() + return .none + } + } + } +} + +struct SharedStateView: View { + @Bindable var store: StoreOf + + var body: some View { + TabView(selection: $store.currentTab.sending(\.selectTab)) { + NavigationStack { + CounterTabView( + store: self.store.scope(state: \.counter, action: \.counter) + ) + } + .tag(SharedState.Tab.counter) + .tabItem { Text("Counter") } + + NavigationStack { + ProfileTabView( + store: self.store.scope(state: \.profile, action: \.profile) + ) + } + .tag(SharedState.Tab.profile) + .tabItem { Text("Profile") } + } + } +} + +class CounterModel: ObservableObject { + @Published var count = 0 +} + +struct Stats: Equatable { + private(set) var count = 0 + private(set) var maxCount = 0 + private(set) var minCount = 0 + private(set) var numberOfCounts = 0 + mutating func increment() { + count += 1 + numberOfCounts += 1 + maxCount = max(maxCount, count) + } + mutating func decrement() { + count -= 1 + numberOfCounts += 1 + minCount = min(minCount, count) + } + mutating func reset() { + self = Self() + } +} + +/// 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 +} + +#Preview { + SharedStateView( + store: Store(initialState: SharedState.State()) { + SharedState() + ._printChanges() + } + ) +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift new file mode 100644 index 00000000..f1c2497f --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift @@ -0,0 +1,159 @@ +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. + """ + +@Reducer +struct EffectsBasics { + @ObservableState + 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 + } + } + } +} + +struct EffectsBasicsView: View { + let store: StoreOf + @Environment(\.openURL) var openURL + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + Section { + HStack { + Button { + store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } + + Text("\(store.count)") + .monospacedDigit() + + Button { + store.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } + } + .frame(maxWidth: .infinity) + + Button("Number fact") { store.send(.numberFactButtonTapped) } + .frame(maxWidth: .infinity) + + if store.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 = store.numberFact { + Text(numberFact) + } + } + + Section { + Button("Number facts provided by numbersapi.com") { + openURL(URL(string: "http://numbersapi.com")!) + } + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderless) + .navigationTitle("Effects") + } +} + +#Preview { + NavigationStack { + EffectsBasicsView( + store: Store(initialState: EffectsBasics.State()) { + EffectsBasics() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift new file mode 100644 index 00000000..ce8a24fa --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift @@ -0,0 +1,122 @@ +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. + """ + +@Reducer +struct EffectsCancellation { + @ObservableState + 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 + } + } + } +} + +struct EffectsCancellationView: View { + @Bindable var store: StoreOf + @Environment(\.openURL) var openURL + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + Section { + Stepper("\(store.count)", value: $store.count.sending(\.stepperChanged)) + + if store.isFactRequestInFlight { + HStack { + Button("Cancel") { store.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") { store.send(.factButtonTapped) } + .disabled(store.isFactRequestInFlight) + } + + if let fact = store.currentFact { + Text(fact).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") + } +} + +#Preview { + NavigationStack { + EffectsCancellationView( + store: Store(initialState: EffectsCancellation.State()) { + EffectsCancellation() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift new file mode 100644 index 00000000..586b8e03 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift @@ -0,0 +1,111 @@ +import ComposableArchitecture +import SwiftUI + +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. + """ + +@Reducer +struct LongLivingEffects { + @ObservableState + 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 } + ) + } +} + +struct LongLivingEffectsView: View { + let store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + Text("A screenshot of this screen has been taken \(store.screenshotCount) times.") + .font(.headline) + + Section { + NavigationLink { + detailView + } label: { + Text("Navigate to another screen") + } + } + } + .navigationTitle("Long-living effects") + .task { await store.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) + } +} + +#Preview { + NavigationStack { + LongLivingEffectsView( + store: Store(initialState: LongLivingEffects.State()) { + LongLivingEffects() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift new file mode 100644 index 00000000..46271c04 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Refreshable.swift @@ -0,0 +1,123 @@ +import ComposableArchitecture +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 a discardable task that is returned from the store's `.send` method representing any \ + effects kicked off by the reducer. You can `await` this task using its `.finish` method, which \ + will suspend while the effects remain in flight. This suspension communicates to SwiftUI that \ + you are currently fetching data so that it knows to continue showing the loading indicator. + """ + +@Reducer +struct Refreshable { + @ObservableState + 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) + } + } + } +} + +struct RefreshableView: View { + let store: StoreOf + @State var isLoading = false + + var body: some View { + List { + Section { + AboutView(readMe: readMe) + } + + HStack { + Button { + store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } + + Text("\(store.count)") + .monospacedDigit() + + Button { + store.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } + } + .frame(maxWidth: .infinity) + .buttonStyle(.borderless) + + if let fact = store.fact { + Text(fact) + .bold() + } + if self.isLoading { + Button("Cancel") { + store.send(.cancelButtonTapped, animation: .default) + } + } + } + .refreshable { + isLoading = true + defer { isLoading = false } + await store.send(.refresh).finish() + } + } +} + +#Preview { + RefreshableView( + store: Store(initialState: Refreshable.State()) { + Refreshable() + } + ) +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-SystemEnvironment.swift new file mode 100644 index 00000000..294498ba --- /dev/null +++ b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift new file mode 100644 index 00000000..7b143f60 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift @@ -0,0 +1,123 @@ +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. + """ + +@Reducer +struct Timers { + @ObservableState + 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) + } + } + } +} + +struct TimersView: View { + var store: StoreOf + + var body: some View { + 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(store.secondsElapsed) * 360 / 60)) + } + } + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: 280) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + + Button { + store.send(.toggleTimerButtonTapped) + } label: { + Text(store.isTimerActive ? "Stop" : "Start") + .padding(8) + } + .frame(maxWidth: .infinity) + .tint(store.isTimerActive ? Color.red : .accentColor) + .buttonStyle(.borderedProminent) + } + .navigationTitle("Timers") + .onDisappear { + store.send(.onDisappear) + } + } +} + +#Preview { + NavigationStack { + TimersView( + store: Store(initialState: Timers.State()) { + Timers() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift new file mode 100644 index 00000000..07f867e4 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift @@ -0,0 +1,360 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This application demonstrates how to work with a web socket in the Composable Architecture. + + A lightweight wrapper is made for `URLSession`'s API for web sockets so that we can send, \ + receive and ping a socket endpoint. To test, connect to the socket server, and then send a \ + message. The socket server should immediately reply with the exact message you sent in. + """ + +@Reducer +struct WebSocket { + @ObservableState + struct State: Equatable { + @Presents 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( + id: WebSocketClient.ID(), + url: URL(string: "wss://echo.websocket.events")!, + protocols: [] + ) + 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(id: WebSocketClient.ID()) + } + } + group.addTask { + for await result in try await self.webSocket.receive(id: 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(id: WebSocketClient.ID(), message: .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) + } +} + +struct WebSocketView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + Section { + VStack(alignment: .leading) { + Button( + store.connectivityState == .connected + ? "Disconnect" + : store.connectivityState == .disconnected + ? "Connect" + : "Connecting..." + ) { + store.send(.connectButtonTapped) + } + .buttonStyle(.bordered) + .tint(store.connectivityState == .connected ? .red : .green) + + HStack { + TextField( + "Type message here", + text: $store.messageToSend.sending(\.messageToSendChanged) + ) + .textFieldStyle(.roundedBorder) + + Button("Send") { + store.send(.sendButtonTapped) + } + .buttonStyle(.borderless) + } + } + } + + Section { + Text("Status: \(store.connectivityState.rawValue)") + .foregroundStyle(.secondary) + Text(store.receivedMessages.reversed().joined(separator: "\n")) + } header: { + Text("Received messages") + } + } + .alert($store.scope(state: \.alert, action: \.alert)) + .navigationTitle("Web Socket") + } +} + +@DependencyClient +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: ID, _ url: URL, _ protocols: [String]) async -> AsyncStream = { + _, _, _ in .finished + } + var receive: @Sendable (_ id: ID) async throws -> AsyncStream> + var send: @Sendable (_ id: ID, _ message: URLSessionWebSocketTask.Message) async throws -> Void + var sendPing: @Sendable (_ id: 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 { + 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() +} + +extension DependencyValues { + var webSocket: WebSocketClient { + get { self[WebSocketClient.self] } + set { self[WebSocketClient.self] = newValue } + } +} + +#Preview { + NavigationStack { + WebSocketView( + store: Store(initialState: WebSocket.State(receivedMessages: ["Hi"])) { + WebSocket() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift new file mode 100644 index 00000000..7f33b713 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Lists-NavigateAndLoad.swift @@ -0,0 +1,120 @@ +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. + """ + +@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() + } + } + } +} + +struct NavigateAndLoadListView: View { + @Bindable var store: StoreOf + + 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") + } +} + +#Preview { + 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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Multiple-Destinations.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Multiple-Destinations.swift new file mode 100644 index 00000000..f82f41a7 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Multiple-Destinations.swift @@ -0,0 +1,84 @@ +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(state: .equatable) + enum Destination { + case drillDown(Counter) + case popover(Counter) + case sheet(Counter) + } + + @ObservableState + struct State: Equatable { + @Presents 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) + } +} + +struct MultipleDestinationsView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + Button("Show drill-down") { + store.send(.showDrillDown) + } + Button("Show popover") { + store.send(.showPopover) + } + Button("Show sheet") { + store.send(.showSheet) + } + } + .navigationDestination( + item: $store.scope(state: \.destination?.drillDown, action: \.destination.drillDown) + ) { store in + CounterView(store: store) + } + .popover( + item: $store.scope(state: \.destination?.popover, action: \.destination.popover) + ) { store in + CounterView(store: store) + } + .sheet( + item: $store.scope(state: \.destination?.sheet, action: \.destination.sheet) + ) { store in + CounterView(store: store) + } + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift new file mode 100644 index 00000000..f51a9f53 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-NavigateAndLoad.swift @@ -0,0 +1,90 @@ +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. + """ + +@Reducer +struct NavigateAndLoad { + @ObservableState + 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() + } + } +} + +struct NavigateAndLoadView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + NavigationLink( + "Load optional counter", + isActive: $store.isNavigationActive.sending(\.setNavigation) + ) { + if let store = store.scope(state: \.optionalCounter, action: \.optionalCounter) { + CounterView(store: store) + } else { + ProgressView() + } + } + } + .navigationTitle("Navigate and load") + } +} + +#Preview { + NavigationView { + NavigateAndLoadView( + store: Store(initialState: NavigateAndLoad.State()) { + NavigateAndLoad() + } + ) + } + .navigationViewStyle(.stack) +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift new file mode 100644 index 00000000..0a970637 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-LoadThenPresent.swift @@ -0,0 +1,89 @@ +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. + """ + +@Reducer +struct LoadThenPresent { + @ObservableState + struct State: Equatable { + @Presents 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() + } + } +} + +struct LoadThenPresentView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + Button { + store.send(.counterButtonTapped) + } label: { + HStack { + Text("Load optional counter") + if store.isActivityIndicatorVisible { + Spacer() + ProgressView() + } + } + } + } + .sheet(item: $store.scope(state: \.counter, action: \.counter)) { store in + CounterView(store: store) + } + .navigationTitle("Load and present") + } +} + +#Preview { + NavigationStack { + LoadThenPresentView( + store: Store(initialState: LoadThenPresent.State()) { + LoadThenPresent() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift new file mode 100644 index 00000000..4159de66 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-Navigation-Sheet-PresentAndLoad.swift @@ -0,0 +1,89 @@ +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. + """ + +@Reducer +struct PresentAndLoad { + @ObservableState + 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() + } + } +} + +struct PresentAndLoadView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + Button("Load optional counter") { + store.send(.setSheet(isPresented: true)) + } + } + .sheet(isPresented: $store.isSheetPresented.sending(\.setSheet)) { + if let store = store.scope(state: \.optionalCounter, action: \.optionalCounter) { + CounterView(store: store) + } else { + ProgressView() + } + } + .navigationTitle("Present and load") + } +} + +#Preview { + NavigationView { + PresentAndLoadView( + store: Store(initialState: PresentAndLoad.State()) { + PresentAndLoad() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift new file mode 100644 index 00000000..af53ffb5 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/03-NavigationStack.swift @@ -0,0 +1,454 @@ +import ComposableArchitecture +import SwiftUI + +private let readMe = """ + This screen demonstrates how to use `NavigationStack` with Composable Architecture applications. + """ + +@Reducer +struct NavigationDemo { + @Reducer(state: .equatable) + enum Path { + case screenA(ScreenA) + case screenB(ScreenB) + case screenC(ScreenC) + } + + @ObservableState + 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(ScreenA.State())) + state.path.append(.screenB(ScreenB.State())) + state.path.append(.screenC(ScreenC.State())) + return .none + + case let .path(action): + switch action { + case .element(id: _, action: .screenB(.screenAButtonTapped)): + state.path.append(.screenA(ScreenA.State())) + return .none + + case .element(id: _, action: .screenB(.screenBButtonTapped)): + state.path.append(.screenB(ScreenB.State())) + return .none + + case .element(id: _, action: .screenB(.screenCButtonTapped)): + state.path.append(.screenC(ScreenC.State())) + return .none + + default: + return .none + } + + case .popToRoot: + state.path.removeAll() + return .none + } + } + .forEach(\.path, action: \.path) + } +} + +struct NavigationDemoView: View { + @Bindable var store: StoreOf + + var body: some View { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + Form { + Section { Text(template: readMe) } + + Section { + NavigationLink( + "Go to screen A", + state: NavigationDemo.Path.State.screenA(ScreenA.State()) + ) + NavigationLink( + "Go to screen B", + state: NavigationDemo.Path.State.screenB(ScreenB.State()) + ) + NavigationLink( + "Go to screen C", + state: NavigationDemo.Path.State.screenC(ScreenC.State()) + ) + } + + Section { + Button("Go to A → B → C") { + store.send(.goToABCButtonTapped) + } + } + } + .navigationTitle("Root") + } destination: { store in + switch store.case { + case let .screenA(store): + ScreenAView(store: store) + case let .screenB(store): + ScreenBView(store: store) + case let .screenC(store): + ScreenCView(store: store) + } + } + .safeAreaInset(edge: .bottom) { + FloatingMenuView(store: 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 { + let viewState = ViewState(state: store.state) + if viewState.currentStack.count > 0 { + VStack(alignment: .center) { + Text("Total count: \(viewState.total)") + Button("Pop to root") { + store.send(.popToRoot, animation: .default) + } + Menu("Current stack") { + ForEach(viewState.currentStack) { screen in + Button("\(String(describing: screen.id))) \(screen.name)") { + store.send(.goBackToScreen(id: screen.id)) + } + .disabled(screen == viewState.currentStack.first) + } + Button("Root") { + store.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 { + @ObservableState + struct State: Equatable { + 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 { + 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("\(store.count)") + Spacer() + Button { + store.send(.decrementButtonTapped) + } label: { + Image(systemName: "minus") + } + Button { + store.send(.incrementButtonTapped) + } label: { + Image(systemName: "plus") + } + } + .buttonStyle(.borderless) + + Button { + store.send(.factButtonTapped) + } label: { + HStack { + Text("Get fact") + if store.isLoading { + Spacer() + ProgressView() + } + } + } + + if let fact = store.fact { + Text(fact) + } + } + + Section { + Button("Dismiss") { + store.send(.dismissButtonTapped) + } + } + + Section { + NavigationLink( + "Go to screen A", + state: NavigationDemo.Path.State.screenA(ScreenA.State(count: store.count)) + ) + NavigationLink( + "Go to screen B", + state: NavigationDemo.Path.State.screenB(ScreenB.State()) + ) + NavigationLink( + "Go to screen C", + state: NavigationDemo.Path.State.screenC(ScreenC.State(count: store.count)) + ) + } + } + .navigationTitle("Screen A") + } +} + +// MARK: - Screen B + +@Reducer +struct ScreenB { + @ObservableState + struct State: Equatable {} + + 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") { + store.send(.screenAButtonTapped) + } + Button("Decoupled navigation to screen B") { + store.send(.screenBButtonTapped) + } + Button("Decoupled navigation to screen C") { + store.send(.screenCButtonTapped) + } + } + .navigationTitle("Screen B") + } +} + +// MARK: - Screen C + +@Reducer +struct ScreenC { + @ObservableState + struct State: Equatable { + 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 { + 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("\(store.count)") + if store.isTimerRunning { + Button("Stop timer") { store.send(.stopButtonTapped) } + } else { + Button("Start timer") { store.send(.startButtonTapped) } + } + } + + Section { + NavigationLink( + "Go to screen A", + state: NavigationDemo.Path.State.screenA(ScreenA.State(count: store.count)) + ) + NavigationLink( + "Go to screen B", + state: NavigationDemo.Path.State.screenB(ScreenB.State()) + ) + NavigationLink( + "Go to screen C", + state: NavigationDemo.Path.State.screenC(ScreenC.State()) + ) + } + } + .navigationTitle("Screen C") + } +} + +// MARK: - Previews + +#Preview { + NavigationDemoView( + store: Store(initialState: NavigationDemo.State()) { + NavigationDemo() + } + ) +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift new file mode 100644 index 00000000..495c8965 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift @@ -0,0 +1,122 @@ +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. + """ + +@Reducer +struct Nested { + @ObservableState + struct State: Equatable, Identifiable { + let id: UUID + var name: String = "" + var rows: IdentifiedArrayOf = [] + + init(id: UUID? = nil, name: String = "", rows: IdentifiedArrayOf = []) { + @Dependency(\.uuid) var uuid + self.id = id ?? uuid() + self.name = name + self.rows = rows + } + } + + 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() + } + } +} + +struct NestedView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + AboutView(readMe: readMe) + } + + ForEach(store.scope(state: \.rows, action: \.rows)) { rowStore in + @Bindable var rowStore = rowStore + NavigationLink { + NestedView(store: rowStore) + } label: { + HStack { + TextField("Untitled", text: $rowStore.name.sending(\.nameTextFieldChanged)) + Text("Next") + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + .onDelete { store.send(.onDelete($0)) } + } + .navigationTitle(store.name.isEmpty ? "Untitled" : store.name) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Add row") { store.send(.addRowButtonTapped) } + } + } + } +} + +#Preview { + NavigationView { + NestedView( + store: Store( + initialState: Nested.State( + name: "Foo", + rows: [ + Nested.State( + name: "Bar", + rows: [ + Nested.State() + ] + ), + Nested.State( + name: "Baz", + rows: [ + Nested.State(name: "Fizz"), + Nested.State(name: "Buzz"), + ] + ), + Nested.State(), + ] + ) + ) { + Nested() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift new file mode 100644 index 00000000..d0395dfb --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadClient.swift @@ -0,0 +1,51 @@ +import ComposableArchitecture +import Foundation + +@DependencyClient +struct DownloadClient { + var download: @Sendable (_ url: URL) -> AsyncThrowingStream = { _ in .finished() } + + @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() +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift new file mode 100644 index 00000000..cfbef742 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/DownloadComponent.swift @@ -0,0 +1,179 @@ +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.mode = .notDownloaded + return .none + + case .alert(.presented(.stopButtonTapped)): + state.mode = .notDownloaded + 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: 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)) + } + } +} + +#Preview { + DownloadComponentView( + store: Store( + initialState: DownloadComponent.State( + id: "deadbeef", + mode: .notDownloaded, + url: URL(fileURLWithPath: "/") + ) + ) {} + ) +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift new file mode 100644 index 00000000..2a556d37 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift @@ -0,0 +1,268 @@ +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 = .mocks + } + + enum Action { + case cityMaps(IdentifiedActionOf) + } + + var body: some Reducer { + EmptyReducer().forEach(\.cityMaps, action: \.cityMaps) { + CityMap() + } + } +} + +struct CitiesView: View { + let store: StoreOf + + 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") + } +} + +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 + ), + ] +} + +#Preview("List") { + NavigationStack { + CitiesView( + store: Store(initialState: MapApp.State(cityMaps: .mocks)) { + MapApp() + } + ) + } +} + +#Preview("Detail") { + NavigationView { + CityMapDetailView( + store: Store(initialState: IdentifiedArrayOf.mocks[0]) {} + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift new file mode 100644 index 00000000..cb0031f1 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ReusableFavoriting.swift @@ -0,0 +1,203 @@ +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. + """ + +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)) + } + } +} + +@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) + } + } +} + +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) + } + + var favorite: @Sendable (UUID, Bool) async throws -> Bool = favoriteRequest + + var body: some Reducer { + Reduce { state, action in + .none + } + .forEach(\.episodes, action: \.episodes) { + Episode(favorite: self.favorite) + } + } +} + +struct EpisodesView: View { + let store: StoreOf + + 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") + } +} + +struct FavoriteError: LocalizedError, Equatable { + var errorDescription: String? { + "Favoriting failed." + } +} + +@Sendable private func favoriteRequest(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"), + ] +} + +#Preview { + NavigationStack { + EpisodesView( + store: Store(initialState: Episodes.State()) { + Episodes() + } + ) + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png differ diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png differ diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-iPadPro@2x.png b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon-iPadPro@2x.png differ diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..4f45077f --- /dev/null +++ b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/transparent.png b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/AppIcon.appiconset/transparent.png differ diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift new file mode 100644 index 00000000..3fa429ad --- /dev/null +++ b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift new file mode 100644 index 00000000..d54f6884 --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/FactClient.swift @@ -0,0 +1,32 @@ +import ComposableArchitecture +import Foundation + +@DependencyClient +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() +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Info.plist b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Info.plist new file mode 100644 index 00000000..b94c796a --- /dev/null +++ b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/AboutView.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/AboutView.swift new file mode 100644 index 00000000..6ca62459 --- /dev/null +++ b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift new file mode 100644 index 00000000..c049802a --- /dev/null +++ b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/CircularProgressView.swift @@ -0,0 +1,21 @@ +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) + } +} + +#Preview { + CircularProgressView(value: 0.3).frame(width: 44, height: 44) +} diff --git a/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/ResignFirstResponder.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/ResignFirstResponder.swift new file mode 100644 index 00000000..fce58e01 --- /dev/null +++ b/0272-shared-state-pt5/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/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/TemplateText.swift b/0272-shared-state-pt5/swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/Internal/TemplateText.swift new file mode 100644 index 00000000..9e27a7d3 --- /dev/null +++ b/0272-shared-state-pt5/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