diff --git a/.github/actions/build_and_test/action.yml b/.github/actions/build_and_test/action.yml new file mode 100644 index 000000000..63bc9fe15 --- /dev/null +++ b/.github/actions/build_and_test/action.yml @@ -0,0 +1,48 @@ +name: Build and Test Action + +inputs: + scheme: + required: true + type: string + destination: + required: true + type: string + name: + required: true + type: string + test_plan: + required: false + type: string + generate_project: + required: false + type: boolean + default: true + +runs: + using: "composite" + + steps: + - name: Install Dependencies & Generate project + shell: bash + run: | + if [ "${{ inputs.generate_project }}" = "true" ]; then + make setup_build_tools + make generate + fi + - name: ${{ inputs.name }} + shell: bash + run: | + if [ -n "${{ inputs.test_plan }}" ]; then + xcodebuild clean test \ + -scheme ${{ inputs.scheme }} \ + -destination "${{ inputs.destination }}" \ + -testPlan ${{ inputs.test_plan }} \ + -enableCodeCoverage YES \ + -resultBundlePath "test_output/${{ inputs.name }}.xcresult" || exit 1 + else + xcodebuild clean test \ + -scheme ${{ inputs.scheme }} \ + -destination "${{ inputs.destination }}" \ + -enableCodeCoverage YES \ + -resultBundlePath "test_output/${{ inputs.name }}.xcresult" || exit 1 + fi \ No newline at end of file diff --git a/.github/actions/upload_test_coverage_report/action.yml b/.github/actions/upload_test_coverage_report/action.yml new file mode 100644 index 000000000..3599ff664 --- /dev/null +++ b/.github/actions/upload_test_coverage_report/action.yml @@ -0,0 +1,34 @@ +name: Upload a Test Coverage Report + +inputs: + filename: + required: true + type: string + scheme_name: + required: true + type: string + token: + description: 'A CodeCov Token' + required: true + +runs: + using: "composite" + + steps: + - name: Dir + shell: bash + run: pwd && ls -al + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3.1.0 + with: + token: ${{ inputs.token }} + xcode: true + flags: ${{ inputs.scheme_name }} + xcode_archive_path: test_output/${{ inputs.filename }}.xcresult + - name: Dir + shell: bash + run: pwd && ls -al + - uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.filename }} + path: test_output \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0f8f7ed67..8439109bd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,26 @@ version: 2 updates: - - package-ecosystem: "swift" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: github-actions + directory: / + open-pull-requests-limit: 10 schedule: - interval: "daily" + interval: daily + time: '07:00' + timezone: Europe/Berlin + assignees: + - nik3212 + reviewers: + - nik3212 + + - package-ecosystem: swift + directory: / + open-pull-requests-limit: 10 + schedule: + interval: daily + time: '07:00' + timezone: Europe/Berlin + assignees: + - nik3212 + reviewers: + - nik3212 \ No newline at end of file diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 158ca8723..d165eb3d4 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -18,7 +18,7 @@ jobs: ruby-version: 3.1.4 bundler-cache: true - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup gems run: | gem install bundler diff --git a/.github/workflows/ci.yml b/.github/workflows/flare.yml similarity index 67% rename from .github/workflows/ci.yml rename to .github/workflows/flare.yml index 33acbd8d8..9f23ef429 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/flare.yml @@ -9,21 +9,13 @@ on: paths: - '.swiftlint.yml' - ".github/workflows/**" + - "Package@swift-5.7.swift" + - "Package@swift-5.8.swift" - "Package.swift" - - "Source/**" + - "Source/Flare/**" - "Tests/**" jobs: - SwiftLint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: GitHub Action for SwiftLint - uses: norio-nomura/action-swiftlint@3.2.1 - with: - args: --strict - env: - DIFF_BASE: ${{ github.base_ref }} macOS: name: ${{ matrix.name }} runs-on: ${{ matrix.runsOn }} @@ -47,19 +39,20 @@ jobs: runsOn: macOS-12 name: "macOS 12, Xcode 14.1, Swift 5.7.1" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: ${{ matrix.name }} - run: xcodebuild test -scheme "Flare" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3.1.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} - xcode: true - xcode_archive_path: test_output/${{ matrix.name }}.xcresult - - uses: actions/upload-artifact@v4 + uses: ./.github/actions/build_and_test with: + scheme: Flare + destination: "platform=macOS" name: ${{ matrix.name }} - path: test_output + generate_project: false + - name: Upload test coverage reports to Codecov + uses: ./.github/actions/upload_test_coverage_report + with: + scheme_name: Flare + filename: ${{ matrix.name }} + token: ${{ secrets.CODECOV_TOKEN }} iOS: name: ${{ matrix.name }} @@ -80,17 +73,20 @@ jobs: xcode: "Xcode_14.3.1" runsOn: macos-13 steps: - - uses: actions/checkout@v3 - - name: Install Dependencies - run: make setup_build_tools - - name: Generate project - run: make generate + - uses: actions/checkout@v4 - name: ${{ matrix.name }} - run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan AllTests clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - - uses: actions/upload-artifact@v4 + uses: ./.github/actions/build_and_test with: + scheme: Flare + destination: ${{ matrix.destination }} name: ${{ matrix.name }} - path: test_output + test_plan: AllTests + - name: Upload test coverage reports to Codecov + uses: ./.github/actions/upload_test_coverage_report + with: + scheme_name: Flare + filename: ${{ matrix.name }} + token: ${{ secrets.CODECOV_TOKEN }} tvOS: name: ${{ matrix.name }} @@ -111,23 +107,20 @@ jobs: xcode: "Xcode_14.3.1" runsOn: macos-13 steps: - - uses: actions/checkout@v3 - - name: Install Dependencies - run: make setup_build_tools - - name: Generate project - run: make generate + - uses: actions/checkout@v4 - name: ${{ matrix.name }} - run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan AllTests clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3.1.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} - xcode: true - xcode_archive_path: test_output/${{ matrix.name }}.xcresult - - uses: actions/upload-artifact@v4 + uses: ./.github/actions/build_and_test with: + scheme: Flare + destination: ${{ matrix.destination }} name: ${{ matrix.name }} - path: test_output + test_plan: AllTests + - name: Upload test coverage reports to Codecov + uses: ./.github/actions/upload_test_coverage_report + with: + scheme_name: Flare + filename: ${{ matrix.name }} + token: ${{ secrets.CODECOV_TOKEN }} watchOS: name: ${{ matrix.name }} @@ -152,23 +145,20 @@ jobs: xcode: "Xcode_14.3.1" runsOn: macos-13 steps: - - uses: actions/checkout@v3 - - name: Install Dependencies - run: make setup_build_tools - - name: Generate project - run: make generate + - uses: actions/checkout@v4 - name: ${{ matrix.name }} - run: xcodebuild test -scheme "Flare" -destination "${{ matrix.destination }}" -testPlan UnitTests clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3.1.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} - xcode: true - xcode_archive_path: test_output/${{ matrix.name }}.xcresult - - uses: actions/upload-artifact@v4 + uses: ./.github/actions/build_and_test with: + scheme: Flare + destination: ${{ matrix.destination }} name: ${{ matrix.name }} - path: test_output + test_plan: UnitTests + - name: Upload test coverage reports to Codecov + uses: ./.github/actions/upload_test_coverage_report + with: + scheme_name: Flare + filename: ${{ matrix.name }} + token: ${{ secrets.CODECOV_TOKEN }} spm: name: ${{ matrix.name }} @@ -187,7 +177,7 @@ jobs: xcode: "Xcode_14.3.1" runsOn: macos-13 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: ${{ matrix.name }} run: swift build -c release --target Flare @@ -212,7 +202,7 @@ jobs: env: DEVELOPER_DIR: /Applications/Xcode_14.1.app/Contents/Developer steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Discover typos run: | export PATH="$PATH:/Library/Frameworks/Python.framework/Versions/3.11/bin" @@ -234,6 +224,6 @@ jobs: # name: "visionOS 1.0" # scheme: "Flare" # steps: - # - uses: actions/checkout@v3 + # - uses: actions/checkout@v4 # - name: ${{ matrix.name }} # run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean || exit 1 \ No newline at end of file diff --git a/.github/workflows/flare_ui.yml b/.github/workflows/flare_ui.yml new file mode 100644 index 000000000..d0fa04285 --- /dev/null +++ b/.github/workflows/flare_ui.yml @@ -0,0 +1,267 @@ +name: "flare-ui" + +on: + push: + branches: + - main + - dev + pull_request: + paths: + - '.swiftlint.yml' + - ".github/workflows/**" + - "Package@swift-5.7.swift" + - "Package@swift-5.8.swift" + - "Package.swift" + - "Source/FlareUI/**" + - "Tests/FlareUITests/**" + +jobs: + macOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - xcode: "Xcode_15.0" + runsOn: macos-13 + name: "macOS 13, Xcode 15.0, Swift 5.9.0" + - xcode: "Xcode_14.3.1" + runsOn: macos-13 + name: "macOS 13, Xcode 14.3.1, Swift 5.8.0" + - xcode: "Xcode_14.2" + runsOn: macOS-12 + name: "macOS 12, Xcode 14.2, Swift 5.7.2" + - xcode: "Xcode_14.1" + runsOn: macOS-12 + name: "macOS 12, Xcode 14.1, Swift 5.7.1" + steps: + - uses: actions/checkout@v4 + - name: ${{ matrix.name }} + uses: ./.github/actions/build_and_test + with: + scheme: FlareUI + destination: "platform=macOS" + name: ${{ matrix.name }} + generate_project: false + - name: Upload test coverage reports to Codecov + uses: ./.github/actions/upload_test_coverage_report + with: + scheme_name: FlareUI + filename: ${{ matrix.name }} + token: ${{ secrets.CODECOV_TOKEN }} + + iOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - destination: "OS=17.0.1,name=iPhone 14 Pro" + name: "iOS 17.0.1" + xcode: "Xcode_15.0" + runsOn: macos-13 + - destination: "OS=16.4,name=iPhone 14 Pro" + name: "iOS 16.4" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + steps: + - uses: actions/checkout@v4 + - name: ${{ matrix.name }} + uses: ./.github/actions/build_and_test + with: + scheme: FlareUI + destination: ${{ matrix.destination }} + name: ${{ matrix.name }} + test_plan: FlareUIUnitTests + - name: Upload test coverage reports to Codecov + uses: ./.github/actions/upload_test_coverage_report + with: + scheme_name: FlareUI + filename: ${{ matrix.name }} + token: ${{ secrets.CODECOV_TOKEN }} + + tvOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - destination: "OS=17.0,name=Apple TV" + name: "tvOS 17.0" + xcode: "Xcode_15.0" + runsOn: macos-13 + - destination: "OS=16.4,name=Apple TV" + name: "tvOS 16.4" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + steps: + - uses: actions/checkout@v4 + - name: ${{ matrix.name }} + uses: ./.github/actions/build_and_test + with: + scheme: FlareUI + destination: ${{ matrix.destination }} + name: ${{ matrix.name }} + test_plan: FlareUIUnitTests + - name: Upload test coverage reports to Codecov + uses: ./.github/actions/upload_test_coverage_report + with: + scheme_name: FlareUI + filename: ${{ matrix.name }} + token: ${{ secrets.CODECOV_TOKEN }} + + watchOS: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - destination: "OS=10.0,name=Apple Watch Series 9 (45mm)" + name: "watchOS 10.0" + xcode: "Xcode_15.0" + runsOn: macos-13 + - destination: "OS=9.4,name=Apple Watch Series 8 (45mm)" + name: "watchOS 9.4" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + - destination: "OS=8.5,name=Apple Watch Series 7 (45mm)" + name: "watchOS 8.5" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + steps: + - uses: actions/checkout@v4 + - name: ${{ matrix.name }} + uses: ./.github/actions/build_and_test + with: + scheme: FlareUI + destination: ${{ matrix.destination }} + name: ${{ matrix.name }} + test_plan: FlareUIUnitTests + - name: Upload test coverage reports to Codecov + uses: ./.github/actions/upload_test_coverage_report + with: + scheme_name: FlareUI + filename: ${{ matrix.name }} + token: ${{ secrets.CODECOV_TOKEN }} + + spm: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: "Xcode 15" + xcode: "Xcode_15.0" + runsOn: macos-13 + - name: "Xcode 14" + xcode: "Xcode_14.3.1" + runsOn: macos-13 + steps: + - uses: actions/checkout@v4 + - name: ${{ matrix.name }} + run: swift build -c release --target FlareUI + + snapshots: + name: snapshots / ${{ matrix.name }} + runs-on: ${{ matrix.runsOn }} + env: + DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + # - destination: "platform=macOS" + # xcode: "Xcode_15.0" + # runsOn: macos-13 + # name: "macOS 13, Xcode 15.0, Swift 5.9.0" + - destination: "OS=17.2,name=iPhone 15" + name: "iOS 17.2" + xcode: "Xcode_15.0" + runsOn: macos-13 + - destination: "OS=17.2,name=Apple TV" + name: "tvOS 17.2" + xcode: "Xcode_15.0" + runsOn: macos-13 + steps: + - uses: actions/checkout@v4 + - name: ${{ matrix.name }} + uses: ./.github/actions/build_and_test + with: + scheme: FlareUI + destination: ${{ matrix.destination }} + name: ${{ matrix.name }}SnapshotTests + test_plan: SnapshotTests + - name: Upload test coverage reports to Codecov + uses: ./.github/actions/upload_test_coverage_report + with: + scheme_name: FlareUI + filename: ${{ matrix.name }}SnapshotTests + token: ${{ secrets.CODECOV_TOKEN }} + + merge-test-reports: + needs: [iOS, macOS, watchOS, tvOS] + runs-on: macos-13 + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: test_output + - run: xcrun xcresulttool merge test_output/**/*.xcresult --output-path test_output/final/final.xcresult + - name: Upload Merged Artifact + uses: actions/upload-artifact@v4 + with: + name: MergedResult + path: test_output/final + + discover-typos: + name: Discover Typos + runs-on: macOS-12 + env: + DEVELOPER_DIR: /Applications/Xcode_14.1.app/Contents/Developer + steps: + - uses: actions/checkout@v4 + - name: Discover typos + run: | + export PATH="$PATH:/Library/Frameworks/Python.framework/Versions/3.11/bin" + python3 -m pip install --upgrade pip + python3 -m pip install codespell + codespell --ignore-words-list="hart,inout,msdos,sur" --skip="./.build/*,./.git/*" + + # Beta: + # name: ${{ matrix.name }} + # runs-on: firebreak + # env: + # DEVELOPER_DIR: "/Applications/Xcode_15.0.app/Contents/Developer" + # timeout-minutes: 10 + # strategy: + # fail-fast: false + # matrix: + # include: + # - destination: "OS=1.0,name=Apple Vision Pro" + # name: "visionOS 1.0" + # scheme: "Flare" + # steps: + # - uses: actions/checkout@v4 + # - name: ${{ matrix.name }} + # run: xcodebuild test -scheme "${{ matrix.scheme }}" -destination "${{ matrix.destination }}" clean || exit 1 \ No newline at end of file diff --git a/.github/workflows/publish-pages.yml b/.github/workflows/publish-pages.yml index c5e2c6c7f..3330db120 100644 --- a/.github/workflows/publish-pages.yml +++ b/.github/workflows/publish-pages.yml @@ -5,38 +5,30 @@ on: branches: - main -permissions: - contents: read - pages: write - id-token: write - concurrency: group: "pages" cancel-in-progress: true jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + build: runs-on: macos-12 + strategy: + fail-fast: false + matrix: + include: + - target: "Flare" + base_path: "flare" + - target: "FlareUI" + base_path: "flareui" steps: - name: Checkout 🛎️ - uses: actions/checkout@v3 - - name: Build DocC - run: | - swift build; - swift package \ - --allow-writing-to-directory ./docs \ - generate-documentation \ - --target Flare \ - --output-path ./docs \ - --transform-for-static-hosting \ - --hosting-base-path flare; - - name: Upload artifact - uses: actions/upload-pages-artifact@v1 + uses: actions/checkout@v4 + with: + # Fetch all history for all branches and tags. + fetch-depth: 0 + - name: Build and Push Generated Documentation + uses: space-code/oss-common-actions/.github/actions/publish_docc@main with: - path: 'docs' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v1 \ No newline at end of file + target: ${{ matrix.target }} + output_path: ${{ matrix.base_path }} + hosting_base_path: flare/${{ matrix.base_path }} \ No newline at end of file diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml new file mode 100644 index 000000000..e4f102935 --- /dev/null +++ b/.github/workflows/swiftlint.yml @@ -0,0 +1,26 @@ +name: "swiftlint" + +on: + push: + branches: + - main + - dev + pull_request: + paths: + - '.swiftlint.yml' + - ".github/workflows/**" + - "Package@swift-5.7.swift" + - "Package@swift-5.8.swift" + - "Package.swift" + - "Source/**" + - "Tests/**" + +jobs: + SwiftLint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: GitHub Action for SwiftLint + uses: norio-nomura/action-swiftlint@3.2.1 + with: + args: lint --config ./.swiftlint.yml --strict \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5ca27c72b..a47bd1257 100644 --- a/.gitignore +++ b/.gitignore @@ -67,7 +67,6 @@ playground.xcworkspace Carthage/Build/ # Accio dependency management -Dependencies/ .accio/ # fastlane @@ -88,4 +87,5 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ -*.xcodeproj \ No newline at end of file +*.xcodeproj +Example/ \ No newline at end of file diff --git a/.swiftformat b/.swiftformat index a4294246c..8af055a0b 100644 --- a/.swiftformat +++ b/.swiftformat @@ -33,7 +33,6 @@ --enable redundantPattern --enable redundantRawValues --enable redundantReturn ---enable redundantSelf --enable redundantVoidReturnType --enable semicolons --enable sortImports @@ -57,6 +56,8 @@ --enable markTypes --enable isEmpty +--disable redundantSelf + # format options --wraparguments before-first diff --git a/.swiftlint.yml b/.swiftlint.yml index 530b2f63f..e0943c198 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,6 +4,13 @@ excluded: - Package@swift-5.7.swift - Package@swift-5.8.swift - Sources/Flare/Classes/Generated/Strings.swift + - Sources/FlareUI/Classes/Generated/Strings.swift + - Sources/FlareUI/Classes/Generated/Colors.swift + - Sources/FlareUI/Classes/Generated/Media.swift + - Sources/FlareUI/Classes/Presentation/Components/Core/Constants/UIConstants.swift + - Sources/FlareUIMock/Mocks/ + - Sources/FlareUI/Classes/Presentation/Components/Controllers/ViewController/ViewController.swift + - Sources/FlareUI/Classes/Presentation/Components/Controllers/ViewController/HostingController.swift - .build # Rules @@ -16,7 +23,6 @@ disabled_rules: opt_in_rules: # some rules are only opt-in - anyobject_protocol - - array_init - closure_body_length - closure_end_indentation - closure_spacing @@ -106,7 +112,7 @@ analyzer_rules: - unused_declaration line_length: - warning: 130 + warning: 140 error: 200 type_body_length: @@ -136,4 +142,4 @@ type_name: error: 50 file_name: - excluded: ["Types.swift"] \ No newline at end of file + excluded: ["Types.swift"] diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Flare-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Flare-Package.xcscheme new file mode 100644 index 000000000..c40d994ec --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Flare-Package.xcscheme @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme index 866e55a53..b12f0a4ad 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Flare.xcscheme @@ -34,20 +34,6 @@ ReferencedContainer = "container:"> - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/FlareUITests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/FlareUITests.xcscheme new file mode 100644 index 000000000..bdde5ca5d --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/FlareUITests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c2d4c03..fd8141953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. #### 3.x Releases -- `3.0.0` Release Candidates - [`3.0.0-rc.1`](#300-rc1) +- `3.0.0` Release Candidates - [`3.0.0-rc.1`](#300-rc1) | [`3.0.0-rc.2`](#300-rc2) #### 2.x Releases - `2.0.x` Releases - [2.0.0](#200) @@ -10,6 +10,29 @@ All notable changes to this project will be documented in this file. #### 1.x Releases - `1.0.x` Releases - [1.0.0](#100) +## [3.0.0-rc.2](https://github.com/space-code/flare/releases/tag/3.0.0-rc.2) +Released on 2024-05-10. + +## Added +- Implement the `FlareUI` documentation + - Added in Pull Request [#33](https://github.com/space-code/flare/pull/33). +- Implement the `FlareUI` package + - Added in Pull Request [#28](https://github.com/space-code/flare/pull/28). +- Implement asynchronous transaction completion + - Added in Pull Request [#25](https://github.com/space-code/flare/pull/25). + +## Updated +- Update the `dependabot.yml` configuration + - Updated in Pull Request [#34](https://github.com/space-code/flare/pull/34) + +## Fixed +- Fix handling of cancelling operations + - Fixed in Pull Request [#26](https://github.com/space-code/flare/pull/26). +- Fix switching to the main thread + - Fixed in Pull Request [#24](https://github.com/space-code/flare/pull/24). +- Update the documentation + - Fixed in Pull Request [#23](https://github.com/space-code/flare/pull/23). + ## [3.0.0-rc.1](https://github.com/space-code/flare/releases/tag/3.0.0-rc.1) Released on 2024-02-12. diff --git a/Makefile b/Makefile index 37e687ede..c9a6e5111 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +CHILD_MAKEFILES_DIRS = $(sort $(dir $(wildcard Sources/*/Makefile))) + all: bootstrap bootstrap: hook @@ -16,8 +18,8 @@ lint: fmt: mint run swiftformat Sources Tests -strings: - swiftgen +swiftgen: + @for d in $(CHILD_MAKEFILES_DIRS); do ( cd $$d && make swiftgen; ); done generate: xcodegen generate diff --git a/Package.resolved b/Package.resolved index afc089a5b..a4a420e1b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -44,6 +44,24 @@ "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "e7b77228b34057041374ebef00c0fd7739d71a2b", + "version" : "1.15.3" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 0291e2f6c..4e637807f 100644 --- a/Package.swift +++ b/Package.swift @@ -8,6 +8,7 @@ let visionOSSetting: SwiftSetting = .define("VISION_OS", .when(platforms: [.visi let package = Package( name: "Flare", + defaultLocalization: "en", platforms: [ .macOS(.v10_15), .iOS(.v13), @@ -17,12 +18,17 @@ let package = Package( ], products: [ .library(name: "Flare", targets: ["Flare"]), + .library(name: "FlareUI", targets: ["FlareUI"]), ], dependencies: [ .package(url: "https://github.com/space-code/concurrency.git", .upToNextMajor(from: "0.0.1")), - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.1.0"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), .package(url: "https://github.com/space-code/log.git", .upToNextMajor(from: "1.1.0")), .package(url: "https://github.com/space-code/atomic.git", .upToNextMajor(from: "1.0.0")), + .package( + url: "https://github.com/pointfreeco/swift-snapshot-testing", + from: "1.15.3" + ), ], targets: [ .target( @@ -35,12 +41,36 @@ let package = Package( resources: [.process("Resources")], swiftSettings: [visionOSSetting] ), + .target( + name: "FlareUI", + dependencies: ["Flare"], + resources: [.process("Resources")] + ), + .target(name: "FlareMock", dependencies: ["Flare"]), + .target(name: "FlareUIMock", dependencies: ["FlareMock", "FlareUI"]), .testTarget( name: "FlareTests", dependencies: [ "Flare", + "FlareMock", .product(name: "TestConcurrency", package: "concurrency"), ] ), + .testTarget( + name: "FlareUITests", + dependencies: [ + "FlareUI", + "FlareMock", + "FlareUIMock", + ] + ), + .testTarget( + name: "SnapshotTests", + dependencies: [ + "Flare", + "FlareUIMock", + .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), + ] + ), ] ) diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift index 693b183aa..045522079 100644 --- a/Package@swift-5.7.swift +++ b/Package@swift-5.7.swift @@ -6,6 +6,7 @@ import PackageDescription let package = Package( name: "Flare", + defaultLocalization: "en", platforms: [ .macOS(.v10_15), .iOS(.v13), @@ -14,12 +15,17 @@ let package = Package( ], products: [ .library(name: "Flare", targets: ["Flare"]), + .library(name: "FlareUI", targets: ["FlareUI"]), ], dependencies: [ .package(url: "https://github.com/space-code/concurrency.git", .upToNextMajor(from: "0.0.1")), - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.1.0"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), .package(url: "https://github.com/space-code/log.git", .upToNextMajor(from: "1.1.0")), .package(url: "https://github.com/space-code/atomic.git", .upToNextMajor(from: "1.0.0")), + .package( + url: "https://github.com/pointfreeco/swift-snapshot-testing", + from: "1.15.3" + ), ], targets: [ .target( @@ -31,12 +37,36 @@ let package = Package( ], resources: [.process("Resources")] ), + .target( + name: "FlareUI", + dependencies: ["Flare"], + resources: [.process("Resources")] + ), + .target(name: "FlareMock", dependencies: ["Flare"]), + .target(name: "FlareUIMock", dependencies: ["FlareMock", "FlareUI"]), .testTarget( name: "FlareTests", dependencies: [ "Flare", + "FlareMock", .product(name: "TestConcurrency", package: "concurrency"), ] ), + .testTarget( + name: "FlareUITests", + dependencies: [ + "FlareUI", + "FlareMock", + "FlareUIMock", + ] + ), + .testTarget( + name: "SnapshotTests", + dependencies: [ + "Flare", + "FlareUIMock", + .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), + ] + ), ] ) diff --git a/Package@swift-5.8.swift b/Package@swift-5.8.swift index 87183860a..adca19830 100644 --- a/Package@swift-5.8.swift +++ b/Package@swift-5.8.swift @@ -6,6 +6,7 @@ import PackageDescription let package = Package( name: "Flare", + defaultLocalization: "en", platforms: [ .macOS(.v10_15), .iOS(.v13), @@ -14,12 +15,17 @@ let package = Package( ], products: [ .library(name: "Flare", targets: ["Flare"]), + .library(name: "FlareUI", targets: ["FlareUI"]), ], dependencies: [ .package(url: "https://github.com/space-code/concurrency.git", .upToNextMajor(from: "0.0.1")), - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.1.0"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), .package(url: "https://github.com/space-code/log.git", .upToNextMajor(from: "1.1.0")), .package(url: "https://github.com/space-code/atomic.git", .upToNextMajor(from: "1.0.0")), + .package( + url: "https://github.com/pointfreeco/swift-snapshot-testing", + from: "1.15.3" + ), ], targets: [ .target( @@ -31,12 +37,36 @@ let package = Package( ], resources: [.process("Resources")] ), + .target( + name: "FlareUI", + dependencies: ["Flare"], + resources: [.process("Resources")] + ), + .target(name: "FlareMock", dependencies: ["Flare"]), + .target(name: "FlareUIMock", dependencies: ["FlareMock", "FlareUI"]), .testTarget( name: "FlareTests", dependencies: [ "Flare", + "FlareMock", .product(name: "TestConcurrency", package: "concurrency"), ] ), + .testTarget( + name: "FlareUITests", + dependencies: [ + "FlareUI", + "FlareMock", + "FlareUIMock", + ] + ), + .testTarget( + name: "SnapshotTests", + dependencies: [ + "Flare", + "FlareUIMock", + .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), + ] + ), ] ) diff --git a/README.md b/README.md index 63b899b10..eb05d6d2a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Flare is a framework written in Swift that makes it easy for you to work with in - [Installation](#installation) - [Communication](#communication) - [Contributing](#contributing) +- [Have a Question](#have-a-question) - [Author](#author) - [License](#license) @@ -29,16 +30,23 @@ Flare is a framework written in Swift that makes it easy for you to work with in - [x] Support Consumable & Non-Consumable Purchases - [x] Support Subscription Purchase - [x] Support Promotional & Introductory Offers +- [x] Support StoreKit and StoreKit 2 - [x] iOS, tvOS, watchOS, macOS, and visionOS compatible -- [x] Complete Unit & Integration Test Coverage +- [x] Complete Unit, Integration & Snapshot Test Coverage +- [x] Offer a UI for building in-app purchase stores in SwiftUI and UIKit ## Documentation -Check out the [flare documentation](https://space-code.github.io/flare/documentation/flare/). +Check out the [documentation](https://space-code.github.io/flare/). + +- [**Flare Docs**](https://space-code.github.io/flare/flare/documentation/flare/) describe how to integrate the main framework +- [**FlareUI Docs**](https://space-code.github.io/flare/flareui/documentation/flareui/) contains information about integrating the UI framework, displaying products and subscriptions, and customizing them. ## Requirements -- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ / visionOS 1.0+ -- Xcode 14.0 -- Swift 5.7 + +| Package | Supported Platforms | XCode | Minimum Swift Version | +| ------------- | --------------------------------------------------------------------- | ----- | --------------------- | +| Flare | iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ / visionOS 1.0+ | 14.2 | 5.7 | +| FlareUI | iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ | 14.2 | 5.7 | ## Installation ### Swift Package Manager @@ -67,6 +75,10 @@ make bootstrap Please feel free to help out with this project! If you see something that could be made better or want a new feature, open up an issue or send a Pull Request! +## Have a Question? + +Contact us via [issues on GitHub](https://github.com/space-code/flare/issues). + ## Author Nikita Vasilev, nv3212@gmail.com diff --git a/Sources/Flare/Classes/Common/Logger.swift b/Sources/Flare/Classes/Common/Logger.swift index 9fa71ee7a..05d887ddf 100644 --- a/Sources/Flare/Classes/Common/Logger.swift +++ b/Sources/Flare/Classes/Common/Logger.swift @@ -14,8 +14,9 @@ enum Logger { private static var defaultLogLevel: LogLevel { #if DEBUG return .debug + #else + return .info #endif - return .info } private static let `default`: Log.Logger = .init( diff --git a/Sources/Flare/Classes/DI/FlareDependencies.swift b/Sources/Flare/Classes/DI/FlareDependencies.swift index f97ae841c..3b78f6d2a 100644 --- a/Sources/Flare/Classes/DI/FlareDependencies.swift +++ b/Sources/Flare/Classes/DI/FlareDependencies.swift @@ -13,7 +13,7 @@ final class FlareDependencies: IFlareDependencies { lazy var iapProvider: IIAPProvider = IAPProvider( paymentQueue: SKPaymentQueue.default(), - productProvider: cachingProductProviderDecorator, + productProvider: sortingProductProviderDecorator, purchaseProvider: purchaseProvider, receiptRefreshProvider: receiptRefreshProvider, refundProvider: refundProvider, @@ -25,6 +25,12 @@ final class FlareDependencies: IFlareDependencies { // MARK: Private + private var sortingProductProviderDecorator: ISortingProductsProviderDecorator { + SortingProductsProviderDecorator( + productProvider: cachingProductProviderDecorator + ) + } + private var cachingProductProviderDecorator: ICachingProductsProviderDecorator { CachingProductsProviderDecorator( productProvider: productProvider, diff --git a/Sources/Flare/Classes/Extensions/Product.SubscriptionInfo.Status+ISubscriptionInfoStatus.swift b/Sources/Flare/Classes/Extensions/Product.SubscriptionInfo.Status+ISubscriptionInfoStatus.swift new file mode 100644 index 000000000..a83c5e878 --- /dev/null +++ b/Sources/Flare/Classes/Extensions/Product.SubscriptionInfo.Status+ISubscriptionInfoStatus.swift @@ -0,0 +1,24 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import struct StoreKit.Product + +// MARK: - ISubscriptionInfoStatus + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension Product.SubscriptionInfo.Status: ISubscriptionInfoStatus { + var renewalState: RenewalState { + RenewalState(self.state) + } + + var subscriptionRenewalInfo: VerificationResult { + switch self.renewalInfo { + case let .verified(renewalInfo): + return .verified(.init(renewalInfo: renewalInfo)) + case let .unverified(renewalInfo, error): + return .unverified(.init(renewalInfo: renewalInfo), error) + } + } +} diff --git a/Sources/Flare/Classes/Flare.swift b/Sources/Flare/Classes/Flare.swift index 9ea74d1f4..d2e5f2842 100644 --- a/Sources/Flare/Classes/Flare.swift +++ b/Sources/Flare/Classes/Flare.swift @@ -60,11 +60,11 @@ public final class Flare { // MARK: IFlare extension Flare: IFlare { - public func fetch(productIDs: Set, completion: @escaping Closure>) { + public func fetch(productIDs: some Collection, completion: @escaping Closure>) { iapProvider.fetch(productIDs: productIDs, completion: completion) } - public func fetch(productIDs: Set) async throws -> [StoreProduct] { + public func fetch(productIDs: some Collection) async throws -> [StoreProduct] { try await iapProvider.fetch(productIDs: productIDs) } @@ -136,7 +136,11 @@ extension Flare: IFlare { iapProvider.finish(transaction: transaction, completion: completion) } - public func addTransactionObserver(fallbackHandler: Closure>?) { + public func finish(transaction: StoreTransaction) async { + await iapProvider.finish(transaction: transaction) + } + + public func addTransactionObserver(fallbackHandler: Closure>?) { iapProvider.addTransactionObserver(fallbackHandler: fallbackHandler) } @@ -149,6 +153,11 @@ extension Flare: IFlare { try await iapProvider.checkEligibility(productIDs: productIDs) } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public func restore() async throws { + try await iapProvider.restore() + } + #if os(iOS) || VISION_OS @available(iOS 15.0, *) @available(macOS, unavailable) diff --git a/Sources/Flare/Classes/Helpers/Async/AsyncHandler.swift b/Sources/Flare/Classes/Helpers/Async/AsyncHandler.swift index f819b1bf5..8ffb43c1f 100644 --- a/Sources/Flare/Classes/Helpers/Async/AsyncHandler.swift +++ b/Sources/Flare/Classes/Helpers/Async/AsyncHandler.swift @@ -1,22 +1,46 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Foundation +// MARK: - AsyncHandler + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) enum AsyncHandler { static func call( + strategy: Strategy = .default, completion: @escaping (Result) -> Void, asyncMethod method: @escaping () async throws -> T ) { - _ = Task { + Task { do { - try completion(.success(await method())) + let result = try await method() + await execute(strategy: strategy) { completion(.success(result)) } } catch { - completion(.failure(error)) + await execute(strategy: strategy) { completion(.failure(error)) } } } } + + // MARK: Private + + private static func execute(strategy: Strategy, block: @escaping () -> Void) async { + switch strategy { + case .runOnMain: + await MainActor.run { block() } + case .default: + block() + } + } +} + +// MARK: AsyncHandler.Strategy + +extension AsyncHandler { + enum Strategy { + case runOnMain + case `default` + } } diff --git a/Sources/Flare/Classes/IFlare.swift b/Sources/Flare/Classes/IFlare.swift index 21856a111..c108dd85c 100644 --- a/Sources/Flare/Classes/IFlare.swift +++ b/Sources/Flare/Classes/IFlare.swift @@ -19,7 +19,7 @@ public protocol IFlare { /// - Parameters: /// - productIDs: The list of product identifiers for which you wish to retrieve descriptions. /// - completion: The completion containing the response of retrieving products. - func fetch(productIDs: Set, completion: @escaping Closure>) + func fetch(productIDs: some Collection, completion: @escaping Closure>) /// Retrieves localized information from the App Store about a specified list of products. /// @@ -28,7 +28,7 @@ public protocol IFlare { /// - Throws: `IAPError(error:)` if the request did fail with error. /// /// - Returns: An array of products. - func fetch(productIDs: Set) async throws -> [StoreProduct] + func fetch(productIDs: some Collection) async throws -> [StoreProduct] /// Performs a purchase of a product. /// @@ -125,10 +125,17 @@ public protocol IFlare { /// - completion: If a completion closure is provided, call it after finishing the transaction. func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) + /// Removes a finished (i.e. failed or completed) transaction from the queue. + /// Attempting to finish a purchasing transaction will throw an exception. + /// + /// - Parameters: + /// - transaction: An object in the payment queue. + func finish(transaction: StoreTransaction) async + /// The transactions array will only be synchronized with the server while the queue has observers. /// /// - Note: This may require that the user authenticate. - func addTransactionObserver(fallbackHandler: Closure>?) + func addTransactionObserver(fallbackHandler: Closure>?) /// Removes transaction observer from the payment queue. /// The transactions array will only be synchronized with the server while the queue has observers. @@ -144,6 +151,9 @@ public protocol IFlare { @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func restore() async throws + #if os(iOS) || VISION_OS /// Present the refund request sheet for the specified transaction in a window scene. /// diff --git a/Sources/Flare/Classes/Listeners/TransactionListener/ITransactionListener.swift b/Sources/Flare/Classes/Listeners/TransactionListener/ITransactionListener.swift index 095c12af9..a65aba325 100644 --- a/Sources/Flare/Classes/Listeners/TransactionListener/ITransactionListener.swift +++ b/Sources/Flare/Classes/Listeners/TransactionListener/ITransactionListener.swift @@ -20,4 +20,6 @@ protocol ITransactionListener: Sendable { /// - Note: Available on iOS 15.0+, tvOS 15.0+, macOS 12.0+, watchOS 8.0+. @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) func handle(purchaseResult: StoreKit.Product.PurchaseResult) async throws -> StoreTransaction? + + func set(delegate: TransactionListenerDelegate) async } diff --git a/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift b/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift index 1edd3e410..c57f6c1b5 100644 --- a/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift +++ b/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListener.swift @@ -19,9 +19,12 @@ actor TransactionListener { private let updates: AsyncStream private var task: Task? + private weak var delegate: TransactionListenerDelegate? + // MARK: Initialization - init(updates: S) where S.Element == TransactionResult { + init(delegate: TransactionListenerDelegate? = nil, updates: S) where S.Element == TransactionResult { + self.delegate = delegate self.updates = updates.toAsyncStream() } @@ -29,14 +32,20 @@ actor TransactionListener { private func handle( transactionResult: TransactionResult, - fromTransactionUpdate _: Bool + fromTransactionUpdate: Bool ) async throws -> StoreTransaction { switch transactionResult { case let .verified(transaction): - return StoreTransaction( + let transaction = StoreTransaction( transaction: transaction, jwtRepresentation: transactionResult.jwsRepresentation ) + + if fromTransactionUpdate { + delegate?.transactionListener(self, transactionDidUpdate: .success(transaction)) + } + + return transaction case let .unverified(transaction, verificationError): Logger.info( message: L10n.Purchase.transactionUnverified( @@ -45,9 +54,15 @@ actor TransactionListener { ) ) - throw IAPError.verification( - error: .unverified(productID: transaction.productID, error: verificationError) + let error = IAPError.verification( + error: .init(verificationError) ) + + if fromTransactionUpdate { + delegate?.transactionListener(self, transactionDidUpdate: .failure(error)) + } + + throw error } } } @@ -56,6 +71,10 @@ actor TransactionListener { @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) extension TransactionListener: ITransactionListener { + func set(delegate: TransactionListenerDelegate) { + self.delegate = delegate + } + func listenForTransaction() async { task?.cancel() task = Task(priority: .utility) { [weak self] in diff --git a/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListenerDelegate.swift b/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListenerDelegate.swift new file mode 100644 index 000000000..10bb0880b --- /dev/null +++ b/Sources/Flare/Classes/Listeners/TransactionListener/TransactionListenerDelegate.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +protocol TransactionListenerDelegate: AnyObject { + func transactionListener( + _ transactionListener: ITransactionListener, + transactionDidUpdate result: Result + ) +} diff --git a/Sources/Flare/Classes/Models/ExpirationReason.swift b/Sources/Flare/Classes/Models/ExpirationReason.swift new file mode 100644 index 000000000..f5ed18fcb --- /dev/null +++ b/Sources/Flare/Classes/Models/ExpirationReason.swift @@ -0,0 +1,37 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - ExpirationReason + +public enum ExpirationReason { + case autoRenewDisabled + case billingError + case didNotConsentToPriceIncrease + case productUnavailable + case unknown +} + +extension ExpirationReason { + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + init(expirationReason: Product.SubscriptionInfo.RenewalInfo.ExpirationReason) { + switch expirationReason { + case .autoRenewDisabled: + self = .autoRenewDisabled + case .billingError: + self = .billingError + case .didNotConsentToPriceIncrease: + self = .didNotConsentToPriceIncrease + case .productUnavailable: + self = .productUnavailable + case .unknown: + self = .unknown + default: + self = .unknown + } + } +} diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/IRenewalInfo.swift b/Sources/Flare/Classes/Models/Internal/Protocols/IRenewalInfo.swift new file mode 100644 index 000000000..3d48c0e93 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/Protocols/IRenewalInfo.swift @@ -0,0 +1,46 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +public protocol IRenewalInfo { + /// The JSON representation of the renewal information. + var jsonRepresentation: Data { get } + + /// The original transaction identifier for the subscription group. + var originalTransactionID: UInt64 { get } + + /// The currently active product identifier, or the most recently active product identifier if the + /// subscription is expired. + var currentProductID: String { get } + + /// Whether the subscription will auto renew at the end of the current billing period. + var willAutoRenew: Bool { get } + + /// The product identifier the subscription will auto renew to at the end of the current billing period. + /// + /// If the user disabled auto renewing, this property will be `nil`. + var autoRenewPreference: String? { get } + + /// The reason the subscription expired. + var expirationReason: ExpirationReason? { get } + + /// The status of a price increase for the user. + var priceIncreaseStatus: PriceIncreaseStatus { get } + + /// Whether the subscription is in a billing retry period. + var isInBillingRetry: Bool { get } + + /// The date the billing grace period will expire. + var gracePeriodExpirationDate: Date? { get } + + /// Identifies the offer that will be applied to the next billing period. + /// + /// If `offerType` is `promotional`, this will be the offer identifier. If `offerType` is + /// `code`, this will be the offer code reference name. This will be `nil` for `introductory` + /// offers and if there will be no offer applied for the next billing period. + var offerID: String? { get } +} diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift index d649481ef..95f96964e 100644 --- a/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift +++ b/Sources/Flare/Classes/Models/Internal/Protocols/ISKProduct.swift @@ -42,4 +42,7 @@ protocol ISKProduct { /// The subscription group identifier. var subscriptionGroupIdentifier: String? { get } + + /// The subscription info. + var subscription: SubscriptionInfo? { get } } diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/ISubscriptionInfo.swift b/Sources/Flare/Classes/Models/Internal/Protocols/ISubscriptionInfo.swift new file mode 100644 index 000000000..8b8d59e65 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/Protocols/ISubscriptionInfo.swift @@ -0,0 +1,10 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +protocol ISubscriptionInfo { + var subscriptionStatus: [SubscriptionInfoStatus] { get async throws } +} diff --git a/Sources/Flare/Classes/Models/Internal/Protocols/ISubscriptionInfoStatus.swift b/Sources/Flare/Classes/Models/Internal/Protocols/ISubscriptionInfoStatus.swift new file mode 100644 index 000000000..7ed14f1a5 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/Protocols/ISubscriptionInfoStatus.swift @@ -0,0 +1,11 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +protocol ISubscriptionInfoStatus { + var renewalState: RenewalState { get } + var subscriptionRenewalInfo: VerificationResult { get } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift index 44722d38a..a118e5bb7 100644 --- a/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift +++ b/Sources/Flare/Classes/Models/Internal/SK1StoreProduct.swift @@ -77,4 +77,8 @@ extension SK1StoreProduct: ISKProduct { var subscriptionGroupIdentifier: String? { product.subscriptionGroupIdentifier } + + var subscription: SubscriptionInfo? { + nil + } } diff --git a/Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift b/Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift new file mode 100644 index 000000000..03e8387ba --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK2RenewalInfo.swift @@ -0,0 +1,69 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import StoreKit + +// MARK: - SK2RenewalInfo + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +struct SK2RenewalInfo { + // MARK: Properties + + let underlyingRenewalInfo: Product.SubscriptionInfo.RenewalInfo + + // MARK: Initialization + + init(underlyingRenewalInfo: Product.SubscriptionInfo.RenewalInfo) { + self.underlyingRenewalInfo = underlyingRenewalInfo + } +} + +// MARK: IRenewalInfo + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension SK2RenewalInfo: IRenewalInfo { + var jsonRepresentation: Data { + underlyingRenewalInfo.jsonRepresentation + } + + var originalTransactionID: UInt64 { + underlyingRenewalInfo.originalTransactionID + } + + var willAutoRenew: Bool { + underlyingRenewalInfo.willAutoRenew + } + + var autoRenewPreference: String? { + underlyingRenewalInfo.autoRenewPreference + } + + var isInBillingRetry: Bool { + underlyingRenewalInfo.isInBillingRetry + } + + var gracePeriodExpirationDate: Date? { + underlyingRenewalInfo.gracePeriodExpirationDate + } + + var offerID: String? { + underlyingRenewalInfo.offerID + } + + var currentProductID: String { + underlyingRenewalInfo.currentProductID + } + + var expirationReason: ExpirationReason? { + guard let expirationReason = self.underlyingRenewalInfo.expirationReason else { + return nil + } + return ExpirationReason(expirationReason: expirationReason) + } + + var priceIncreaseStatus: PriceIncreaseStatus { + PriceIncreaseStatus(underlyingRenewalInfo.priceIncreaseStatus) + } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift index ad99a8987..d9a1d4e46 100644 --- a/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift +++ b/Sources/Flare/Classes/Models/Internal/SK2StoreProduct.swift @@ -85,4 +85,11 @@ extension SK2StoreProduct: ISKProduct { var subscriptionGroupIdentifier: String? { product.subscription?.subscriptionGroupID } + + var subscription: SubscriptionInfo? { + guard let subscription = product.subscription else { + return nil + } + return SubscriptionInfo(subscriptionInfo: subscription) + } } diff --git a/Sources/Flare/Classes/Models/Internal/SK2SubscriptionInfo.swift b/Sources/Flare/Classes/Models/Internal/SK2SubscriptionInfo.swift new file mode 100644 index 000000000..92081fa8c --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK2SubscriptionInfo.swift @@ -0,0 +1,32 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import StoreKit + +// MARK: - SK2SubscriptionInfo + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +struct SK2SubscriptionInfo { + // MARK: Properties + + private let underlyingInfo: Product.SubscriptionInfo + + // MARK: Initialization + + init(underlyingInfo: Product.SubscriptionInfo) { + self.underlyingInfo = underlyingInfo + } +} + +// MARK: ISubscriptionInfo + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension SK2SubscriptionInfo: ISubscriptionInfo { + var subscriptionStatus: [SubscriptionInfoStatus] { + get async throws { + try await self.underlyingInfo.status.map { SubscriptionInfoStatus(underlyingStatus: $0) } + } + } +} diff --git a/Sources/Flare/Classes/Models/Internal/SK2SubscriptionInfoStatus.swift b/Sources/Flare/Classes/Models/Internal/SK2SubscriptionInfoStatus.swift new file mode 100644 index 000000000..b438ba9c6 --- /dev/null +++ b/Sources/Flare/Classes/Models/Internal/SK2SubscriptionInfoStatus.swift @@ -0,0 +1,34 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import StoreKit + +// MARK: - SK2SubscriptionInfoStatus + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +struct SK2SubscriptionInfoStatus { + // MARK: Properties + + let underlyingStatus: Product.SubscriptionInfo.Status + + // MARK: Initialization + + init(underlyingStatus: Product.SubscriptionInfo.Status) { + self.underlyingStatus = underlyingStatus + } +} + +// MARK: ISubscriptionInfoStatus + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +extension SK2SubscriptionInfoStatus: ISubscriptionInfoStatus { + var renewalState: RenewalState { + underlyingStatus.renewalState + } + + var subscriptionRenewalInfo: VerificationResult { + underlyingStatus.subscriptionRenewalInfo + } +} diff --git a/Sources/Flare/Classes/Models/PriceIncreaseStatus.swift b/Sources/Flare/Classes/Models/PriceIncreaseStatus.swift new file mode 100644 index 000000000..1a02c8f21 --- /dev/null +++ b/Sources/Flare/Classes/Models/PriceIncreaseStatus.swift @@ -0,0 +1,29 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import StoreKit + +// MARK: - PriceIncreaseStatus + +public enum PriceIncreaseStatus { + case noIncreasePending + case pending + case agreed +} + +extension PriceIncreaseStatus { + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + init(_ status: Product.SubscriptionInfo.RenewalInfo.PriceIncreaseStatus) { + switch status { + case .noIncreasePending: + self = .noIncreasePending + case .pending: + self = .pending + case .agreed: + self = .agreed + } + } +} diff --git a/Sources/Flare/Classes/Models/RenewalInfo.swift b/Sources/Flare/Classes/Models/RenewalInfo.swift new file mode 100644 index 000000000..acbd00fa5 --- /dev/null +++ b/Sources/Flare/Classes/Models/RenewalInfo.swift @@ -0,0 +1,73 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import StoreKit + +// MARK: - RenewalInfo + +public struct RenewalInfo { + // MARK: Properties + + let underlyingRenewalInfo: IRenewalInfo + + // MARK: Initialization + + init(underlyingRenewalInfo: IRenewalInfo) { + self.underlyingRenewalInfo = underlyingRenewalInfo + } +} + +// MARK: - Initialization + +extension RenewalInfo { + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + init(renewalInfo: Product.SubscriptionInfo.RenewalInfo) { + self.init(underlyingRenewalInfo: SK2RenewalInfo(underlyingRenewalInfo: renewalInfo)) + } +} + +// MARK: IRenewalInfo + +extension RenewalInfo: IRenewalInfo { + public var jsonRepresentation: Data { + underlyingRenewalInfo.jsonRepresentation + } + + public var originalTransactionID: UInt64 { + underlyingRenewalInfo.originalTransactionID + } + + public var willAutoRenew: Bool { + underlyingRenewalInfo.willAutoRenew + } + + public var autoRenewPreference: String? { + underlyingRenewalInfo.autoRenewPreference + } + + public var isInBillingRetry: Bool { + underlyingRenewalInfo.isInBillingRetry + } + + public var gracePeriodExpirationDate: Date? { + underlyingRenewalInfo.gracePeriodExpirationDate + } + + public var offerID: String? { + underlyingRenewalInfo.offerID + } + + public var currentProductID: String { + underlyingRenewalInfo.currentProductID + } + + public var expirationReason: ExpirationReason? { + underlyingRenewalInfo.expirationReason + } + + public var priceIncreaseStatus: PriceIncreaseStatus { + underlyingRenewalInfo.priceIncreaseStatus + } +} diff --git a/Sources/Flare/Classes/Models/RenewalState.swift b/Sources/Flare/Classes/Models/RenewalState.swift new file mode 100644 index 000000000..ed863df6e --- /dev/null +++ b/Sources/Flare/Classes/Models/RenewalState.swift @@ -0,0 +1,35 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import StoreKit + +public enum RenewalState { + case subscribed + case expired + case inBillingRetryPeriod + case revoked + case inGracePeriod + case unknown + + // MARK: Initialization + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + init(_ state: Product.SubscriptionInfo.RenewalState) { + switch state { + case .subscribed: + self = .subscribed + case .expired: + self = .expired + case .inBillingRetryPeriod: + self = .inBillingRetryPeriod + case .revoked: + self = .revoked + case .inGracePeriod: + self = .inGracePeriod + default: + self = .unknown + } + } +} diff --git a/Sources/Flare/Classes/Models/StoreProduct.swift b/Sources/Flare/Classes/Models/StoreProduct.swift index 3a2c4a7d8..306784694 100644 --- a/Sources/Flare/Classes/Models/StoreProduct.swift +++ b/Sources/Flare/Classes/Models/StoreProduct.swift @@ -15,7 +15,7 @@ public final class StoreProduct: NSObject { /// Protocol representing a Store Kit product. let product: ISKProduct - /// <#Description#> + /// The store kit product. var underlyingProduct: ISKProduct { product } // MARK: Initialization @@ -97,4 +97,8 @@ extension StoreProduct: ISKProduct { public var subscriptionGroupIdentifier: String? { product.subscriptionGroupIdentifier } + + public var subscription: SubscriptionInfo? { + product.subscription + } } diff --git a/Sources/Flare/Classes/Models/StoreTransaction.swift b/Sources/Flare/Classes/Models/StoreTransaction.swift index 0a777b45c..271af0d8d 100644 --- a/Sources/Flare/Classes/Models/StoreTransaction.swift +++ b/Sources/Flare/Classes/Models/StoreTransaction.swift @@ -49,31 +49,31 @@ extension StoreTransaction { // MARK: IStoreTransaction extension StoreTransaction: IStoreTransaction { - var productIdentifier: String { + public var productIdentifier: String { storeTransaction.productIdentifier } - var purchaseDate: Date { + public var purchaseDate: Date { storeTransaction.purchaseDate } - var hasKnownPurchaseDate: Bool { + public var hasKnownPurchaseDate: Bool { storeTransaction.hasKnownPurchaseDate } - var transactionIdentifier: String { + public var transactionIdentifier: String { storeTransaction.transactionIdentifier } - var hasKnownTransactionIdentifier: Bool { + public var hasKnownTransactionIdentifier: Bool { storeTransaction.hasKnownTransactionIdentifier } - var quantity: Int { + public var quantity: Int { storeTransaction.quantity } - var jwsRepresentation: String? { + public var jwsRepresentation: String? { storeTransaction.jwsRepresentation } diff --git a/Sources/Flare/Classes/Models/SubscriptionEligibility.swift b/Sources/Flare/Classes/Models/SubscriptionEligibility.swift index 362884ace..f7e24a78f 100644 --- a/Sources/Flare/Classes/Models/SubscriptionEligibility.swift +++ b/Sources/Flare/Classes/Models/SubscriptionEligibility.swift @@ -5,14 +5,14 @@ import Foundation -// Enumeration defining the eligibility status for a subscription +/// Enumeration defining the eligibility status for a subscription public enum SubscriptionEligibility: Int, Sendable { - // Represents that the subscription is eligible for an offer + /// Represents that the subscription is eligible for an offer case eligible - // Represents that the subscription is not eligible for an offer + /// Represents that the subscription is not eligible for an offer case nonEligible - // Represents that there is no offer available for the subscription + /// Represents that there is no offer available for the subscription case noOffer } diff --git a/Sources/Flare/Classes/Models/SubscriptionInfo.swift b/Sources/Flare/Classes/Models/SubscriptionInfo.swift new file mode 100644 index 000000000..fa4ce37ad --- /dev/null +++ b/Sources/Flare/Classes/Models/SubscriptionInfo.swift @@ -0,0 +1,39 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import StoreKit + +// MARK: - SubscriptionInfo + +public struct SubscriptionInfo { + // MARK: Properties + + let underlyingSubscriptionInfo: ISubscriptionInfo + + // MARK: Initialization + + init(underlyingSubscriptionInfo: ISubscriptionInfo) { + self.underlyingSubscriptionInfo = underlyingSubscriptionInfo + } +} + +// MARK: - Initializators + +extension SubscriptionInfo { + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + init(subscriptionInfo: Product.SubscriptionInfo) { + self.init(underlyingSubscriptionInfo: SK2SubscriptionInfo(underlyingInfo: subscriptionInfo)) + } +} + +// MARK: ISubscriptionInfo + +extension SubscriptionInfo: ISubscriptionInfo { + public var subscriptionStatus: [SubscriptionInfoStatus] { + get async throws { + try await self.underlyingSubscriptionInfo.subscriptionStatus + } + } +} diff --git a/Sources/Flare/Classes/Models/SubscriptionInfoStatus.swift b/Sources/Flare/Classes/Models/SubscriptionInfoStatus.swift new file mode 100644 index 000000000..4e8650def --- /dev/null +++ b/Sources/Flare/Classes/Models/SubscriptionInfoStatus.swift @@ -0,0 +1,32 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - SubscriptionInfoStatus + +public struct SubscriptionInfoStatus { + // MARK: Properties + + let underlyingStatus: ISubscriptionInfoStatus + + // MARK: Initialization + + init(underlyingStatus: ISubscriptionInfoStatus) { + self.underlyingStatus = underlyingStatus + } +} + +// MARK: ISubscriptionInfoStatus + +extension SubscriptionInfoStatus: ISubscriptionInfoStatus { + public var renewalState: RenewalState { + self.underlyingStatus.renewalState + } + + public var subscriptionRenewalInfo: VerificationResult { + self.underlyingStatus.subscriptionRenewalInfo + } +} diff --git a/Sources/Flare/Classes/Models/VerificationError.swift b/Sources/Flare/Classes/Models/VerificationError.swift index fc661b2fa..e732f14e1 100644 --- a/Sources/Flare/Classes/Models/VerificationError.swift +++ b/Sources/Flare/Classes/Models/VerificationError.swift @@ -4,22 +4,58 @@ // import Foundation +import StoreKit // MARK: - VerificationError /// Enumeration representing errors that can occur during verification. public enum VerificationError: Error { - // Case for unverified product with associated productID and error details. - case unverified(productID: String, error: Error) + /// The certificate chain was parsable, but was invalid due to one or more revoked certificates. + /// + /// Trying again later may retrieve valid signed data from the App Store. + case revokedCertificate + + /// The certificate chain was parsable, but it was invalid for signing this data. + case invalidCertificateChain + + /// The device verification properties were invalid for this device. + case invalidDeviceVerification + + /// Th JWS header and any data included in it or it's certificate chain had an invalid encoding. + case invalidEncoding + + /// The certificate chain was valid for signing this data, but the leaf's public key was invalid for the + /// JWS signature. + case invalidSignature + + /// Either the JWS header or any certificate in the chain was missing necessary properties for + /// verification. + case missingRequiredProperties + + /// Unknown error. + case unknown(error: Error) } -// MARK: LocalizedError +// MARK: - Initialization -extension VerificationError: LocalizedError { - public var errorDescription: String? { - switch self { - case let .unverified(productID, error): - return L10n.VerificationError.unverified(productID, error.localizedDescription) +extension VerificationError { + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + init(_ verificationError: StoreKit.VerificationResult.VerificationError) { + switch verificationError { + case .revokedCertificate: + self = .revokedCertificate + case .invalidCertificateChain: + self = .invalidCertificateChain + case .invalidDeviceVerification: + self = .invalidDeviceVerification + case .invalidEncoding: + self = .invalidEncoding + case .invalidSignature: + self = .invalidSignature + case .missingRequiredProperties: + self = .missingRequiredProperties + @unknown default: + self = .unknown(error: verificationError) } } } diff --git a/Sources/Flare/Classes/Models/VerificationResult.swift b/Sources/Flare/Classes/Models/VerificationResult.swift new file mode 100644 index 000000000..f4123440d --- /dev/null +++ b/Sources/Flare/Classes/Models/VerificationResult.swift @@ -0,0 +1,11 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public enum VerificationResult { + case verified(SignedType) + case unverified(SignedType, Error) +} diff --git a/Sources/Flare/Classes/Providers/ConfigurationProvider/IConfigurationProvider.swift b/Sources/Flare/Classes/Providers/ConfigurationProvider/IConfigurationProvider.swift index e5e2649b2..88f8014cf 100644 --- a/Sources/Flare/Classes/Providers/ConfigurationProvider/IConfigurationProvider.swift +++ b/Sources/Flare/Classes/Providers/ConfigurationProvider/IConfigurationProvider.swift @@ -10,7 +10,7 @@ protocol IConfigurationProvider { /// The application username. var applicationUsername: String? { get } - /// <#Description#> + /// The cache policy for fetching data. var fetchCachePolicy: FetchCachePolicy { get } /// Configures the provider with the specified configuration settings. diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index bb386a7bf..fd787f988 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -60,13 +60,18 @@ final class IAPProvider: IIAPProvider { paymentQueue.canMakePayments } - func fetch(productIDs: Set, completion: @escaping Closure>) { + func fetch(productIDs: some Collection, completion: @escaping Closure>) { if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { AsyncHandler.call( + strategy: .runOnMain, completion: { (result: Result<[StoreProduct], Error>) in switch result { case let .success(products): - completion(.success(products)) + if products.isEmpty { + completion(.failure(.invalid(productIDs: Array(productIDs)))) + } else { + completion(.success(products)) + } case let .failure(error): completion(.failure(.with(error: error))) } @@ -84,7 +89,7 @@ final class IAPProvider: IIAPProvider { } } - func fetch(productIDs: Set) async throws -> [StoreProduct] { + func fetch(productIDs: some Collection) async throws -> [StoreProduct] { try await withCheckedThrowingContinuation { continuation in self.fetch(productIDs: productIDs) { result in continuation.resume(with: result) @@ -165,7 +170,15 @@ final class IAPProvider: IIAPProvider { purchaseProvider.finish(transaction: transaction, completion: completion) } - func addTransactionObserver(fallbackHandler: Closure>?) { + func finish(transaction: StoreTransaction) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + finish(transaction: transaction) { + continuation.resume(returning: ()) + } + } + } + + func addTransactionObserver(fallbackHandler: Closure>?) { purchaseProvider.addTransactionObserver(fallbackHandler: fallbackHandler) } @@ -179,6 +192,11 @@ final class IAPProvider: IIAPProvider { return try await eligibilityProvider.checkEligibility(products: products) } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func restore() async throws { + try await purchaseProvider.restore() + } + #if os(iOS) || VISION_OS @available(iOS 15.0, *) @available(macOS, unavailable) diff --git a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift index 3e8899936..318a1435f 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IIAPProvider.swift @@ -17,7 +17,7 @@ public protocol IIAPProvider { /// - Parameters: /// - productIDs: The list of product identifiers for which you wish to retrieve descriptions. /// - completion: The completion containing the response of retrieving products. - func fetch(productIDs: Set, completion: @escaping Closure>) + func fetch(productIDs: some Collection, completion: @escaping Closure>) /// Retrieves localized information from the App Store about a specified list of products. /// @@ -26,7 +26,7 @@ public protocol IIAPProvider { /// - Throws: `IAPError(error:)` if the request did fail with error. /// /// - Returns: An array of products. - func fetch(productIDs: Set) async throws -> [StoreProduct] + func fetch(productIDs: some Collection) async throws -> [StoreProduct] /// Performs a purchase of a product. /// @@ -123,11 +123,18 @@ public protocol IIAPProvider { /// - completion: If a completion closure is provided, call it after finishing the transaction. func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) + /// Removes a finished (i.e. failed or completed) transaction from the queue. + /// Attempting to finish a purchasing transaction will throw an exception. + /// + /// - Parameters: + /// - transaction: An object in the payment queue. + func finish(transaction: StoreTransaction) async + /// Adds transaction observer to the payment queue. /// The transactions array will only be synchronized with the server while the queue has observers. /// /// - Note: This may require that the user authenticate. - func addTransactionObserver(fallbackHandler: Closure>?) + func addTransactionObserver(fallbackHandler: Closure>?) /// Removes transaction observer from the payment queue. /// The transactions array will only be synchronized with the server while the queue has observers. @@ -143,6 +150,9 @@ public protocol IIAPProvider { @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) func checkEligibility(productIDs: Set) async throws -> [String: SubscriptionEligibility] + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func restore() async throws + #if os(iOS) || VISION_OS /// Present the refund request sheet for the specified transaction in a window scene. /// diff --git a/Sources/Flare/Classes/Providers/ProductProvider/Decorators/ProductsCacheProviderDecorator/CachingProductsProviderDecorator.swift b/Sources/Flare/Classes/Providers/ProductProvider/Decorators/CachingProductsProviderDecorator/CachingProductsProviderDecorator.swift similarity index 87% rename from Sources/Flare/Classes/Providers/ProductProvider/Decorators/ProductsCacheProviderDecorator/CachingProductsProviderDecorator.swift rename to Sources/Flare/Classes/Providers/ProductProvider/Decorators/CachingProductsProviderDecorator/CachingProductsProviderDecorator.swift index d2a865962..6f993172a 100644 --- a/Sources/Flare/Classes/Providers/ProductProvider/Decorators/ProductsCacheProviderDecorator/CachingProductsProviderDecorator.swift +++ b/Sources/Flare/Classes/Providers/ProductProvider/Decorators/CachingProductsProviderDecorator/CachingProductsProviderDecorator.swift @@ -46,7 +46,7 @@ final class CachingProductsProviderDecorator { /// - Parameter ids: The set of product IDs to retrieve cached products for. /// /// - Returns: A dictionary containing cached products for the specified IDs. - private func cachedProducts(ids: Set) -> [String: StoreProduct] { + private func cachedProducts(ids: some Collection) -> [String: StoreProduct] { let cachedProducts = _cache.wrappedValue.filter { ids.contains($0.key) } return cachedProducts } @@ -58,12 +58,12 @@ final class CachingProductsProviderDecorator { /// - fetcher: A closure to fetch missing products from the product provider. /// - completion: A closure to be called with the fetched products or an error. private func fetch( - productIDs: Set, - fetcher: (Set, @escaping (Result<[StoreProduct], IAPError>) -> Void) -> Void, + productIDs: some Collection, + fetcher: (any Collection, @escaping (Result<[StoreProduct], IAPError>) -> Void) -> Void, completion: @escaping ProductsHandler ) { let cachedProducts = cachedProducts(ids: productIDs) - let missingProducts = productIDs.subtracting(cachedProducts.keys) + let missingProducts = Set(productIDs).subtracting(cachedProducts.keys) if missingProducts.isEmpty { completion(.success(Array(cachedProducts.values))) @@ -89,8 +89,8 @@ final class CachingProductsProviderDecorator { /// - completion: A closure to be called with the fetched products or an error. private func fetch( fetchPolicy: FetchCachePolicy, - productIDs: Set, - fetcher: (Set, @escaping (Result<[StoreProduct], IAPError>) -> Void) -> Void, + productIDs: some Collection, + fetcher: (any Collection, @escaping (Result<[StoreProduct], IAPError>) -> Void) -> Void, completion: @escaping ProductsHandler ) { switch fetchPolicy { @@ -107,7 +107,7 @@ final class CachingProductsProviderDecorator { /// - productIDs: The set of product IDs to check the cache for. /// - completion: A closure to be called with the fetched products or an error. @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - private func fetchSK2Products(productIDs: Set, completion: @escaping ProductsHandler) { + private func fetchSK2Products(productIDs: some Collection, completion: @escaping ProductsHandler) { AsyncHandler.call( completion: { result in switch result { @@ -127,7 +127,7 @@ final class CachingProductsProviderDecorator { // MARK: ICachingProductsProviderDecorator extension CachingProductsProviderDecorator: ICachingProductsProviderDecorator { - func fetch(productIDs: Set, requestID: String, completion: @escaping ProductsHandler) { + func fetch(productIDs: some Collection, requestID: String, completion: @escaping ProductsHandler) { fetch( fetchPolicy: configurationProvider.fetchCachePolicy, productIDs: productIDs, @@ -138,7 +138,7 @@ extension CachingProductsProviderDecorator: ICachingProductsProviderDecorator { } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func fetch(productIDs: Set) async throws -> [StoreProduct] { + func fetch(productIDs: some Collection) async throws -> [StoreProduct] { try await withCheckedThrowingContinuation { [weak self] continuation in guard let self = self else { continuation.resume(throwing: IAPError.unknown) diff --git a/Sources/Flare/Classes/Providers/ProductProvider/Decorators/ProductsCacheProviderDecorator/ICachingProductsProviderDecorator.swift b/Sources/Flare/Classes/Providers/ProductProvider/Decorators/CachingProductsProviderDecorator/ICachingProductsProviderDecorator.swift similarity index 100% rename from Sources/Flare/Classes/Providers/ProductProvider/Decorators/ProductsCacheProviderDecorator/ICachingProductsProviderDecorator.swift rename to Sources/Flare/Classes/Providers/ProductProvider/Decorators/CachingProductsProviderDecorator/ICachingProductsProviderDecorator.swift diff --git a/Sources/Flare/Classes/Providers/ProductProvider/Decorators/SortingProductsProviderDecorator/ISortingProductsProviderDecorator.swift b/Sources/Flare/Classes/Providers/ProductProvider/Decorators/SortingProductsProviderDecorator/ISortingProductsProviderDecorator.swift new file mode 100644 index 000000000..db3d7042e --- /dev/null +++ b/Sources/Flare/Classes/Providers/ProductProvider/Decorators/SortingProductsProviderDecorator/ISortingProductsProviderDecorator.swift @@ -0,0 +1,8 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +protocol ISortingProductsProviderDecorator: IProductProvider {} diff --git a/Sources/Flare/Classes/Providers/ProductProvider/Decorators/SortingProductsProviderDecorator/SortingProductsProviderDecorator.swift b/Sources/Flare/Classes/Providers/ProductProvider/Decorators/SortingProductsProviderDecorator/SortingProductsProviderDecorator.swift new file mode 100644 index 000000000..b687790b8 --- /dev/null +++ b/Sources/Flare/Classes/Providers/ProductProvider/Decorators/SortingProductsProviderDecorator/SortingProductsProviderDecorator.swift @@ -0,0 +1,72 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - SortingProductsProviderDecorator + +final class SortingProductsProviderDecorator { + // MARK: Properties + + private let productProvider: IProductProvider + + // MARK: Initialization + + init(productProvider: IProductProvider) { + self.productProvider = productProvider + } + + // MARK: Private + + private func sort(productIDs: some Collection, products: [StoreProduct]) -> [StoreProduct] { + var sortedProducts: [StoreProduct] = [] + var set = Set(productIDs) + + for productID in productIDs { + if set.contains(productID), let product = products.by(id: productID) { + sortedProducts.append(product) + set.remove(productID) + } + } + + return sortedProducts + } +} + +// MARK: ISortingProductsProviderDecorator + +extension SortingProductsProviderDecorator: ISortingProductsProviderDecorator { + func fetch( + productIDs: some Collection, + requestID: String, + completion: @escaping ProductsHandler + ) { + productProvider.fetch(productIDs: productIDs, requestID: requestID) { [weak self] result in + guard let self = self else { return } + + switch result { + case let .success(products): + let sortedProducts = self.sort(productIDs: productIDs, products: products) + completion(.success(sortedProducts)) + case let .failure(error): + completion(.failure(error)) + } + } + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func fetch(productIDs: some Collection) async throws -> [StoreProduct] { + let products = try await productProvider.fetch(productIDs: productIDs) + return sort(productIDs: productIDs, products: products) + } +} + +// MARK: Private + +private extension Array where Element: StoreProduct { + func by(id: String) -> StoreProduct? { + first(where: { $0.productIdentifier == id }) + } +} diff --git a/Sources/Flare/Classes/Providers/ProductProvider/IProductProvider.swift b/Sources/Flare/Classes/Providers/ProductProvider/IProductProvider.swift index 1e82f3158..55955ecf4 100644 --- a/Sources/Flare/Classes/Providers/ProductProvider/IProductProvider.swift +++ b/Sources/Flare/Classes/Providers/ProductProvider/IProductProvider.swift @@ -22,7 +22,7 @@ protocol IProductProvider { /// - productIDs: The list of product identifiers for which you wish to retrieve descriptions. /// - requestID: The request identifier. /// - completion: The completion containing the response of retrieving products. - func fetch(productIDs: Set, requestID: String, completion: @escaping ProductsHandler) + func fetch(productIDs: some Collection, requestID: String, completion: @escaping ProductsHandler) /// Retrieves localized information from the App Store about a specified list of products. /// @@ -32,5 +32,5 @@ protocol IProductProvider { /// /// - Returns: The requested products. @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func fetch(productIDs: Set) async throws -> [StoreProduct] + func fetch(productIDs: some Collection) async throws -> [StoreProduct] } diff --git a/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift b/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift index 57a6ad833..357229817 100644 --- a/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift +++ b/Sources/Flare/Classes/Providers/ProductProvider/ProductProvider.swift @@ -38,13 +38,13 @@ final class ProductProvider: NSObject, IProductProvider { // MARK: Internal - func fetch(productIDs ids: Set, requestID: String, completion: @escaping ProductsHandler) { + func fetch(productIDs ids: some Collection, requestID: String, completion: @escaping ProductsHandler) { let request = makeRequest(ids: ids, requestID: requestID) fetch(request: request, completion: completion) } @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func fetch(productIDs ids: Set) async throws -> [StoreProduct] { + func fetch(productIDs ids: some Collection) async throws -> [StoreProduct] { try await StoreKit.Product.products(for: ids).map { StoreProduct(product: $0) } } @@ -64,8 +64,8 @@ final class ProductProvider: NSObject, IProductProvider { /// - ids: The set of product IDs to include in the request. /// - requestID: The identifier for the request. /// - Returns: An instance of `SKProductsRequest`. - private func makeRequest(ids: Set, requestID: String) -> SKProductsRequest { - let request = SKProductsRequest(productIdentifiers: ids) + private func makeRequest(ids: some Collection, requestID: String) -> SKProductsRequest { + let request = SKProductsRequest(productIdentifiers: Set(ids)) request.id = requestID request.delegate = self return request diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift index 2fb931923..cd290ad09 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/IPurchaseProvider.swift @@ -23,7 +23,7 @@ protocol IPurchaseProvider { /// The transactions array will only be synchronized with the server while the queue has observers. /// /// - Note: This may require that the user authenticate. - func addTransactionObserver(fallbackHandler: Closure>?) + func addTransactionObserver(fallbackHandler: Closure>?) /// Removes transaction observer from the payment queue. /// The transactions array will only be synchronized with the server while the queue has observers. @@ -57,6 +57,9 @@ protocol IPurchaseProvider { promotionalOffer: PromotionalOffer?, completion: @escaping PurchaseCompletionHandler ) + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func restore() async throws } extension IPurchaseProvider { diff --git a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift index b78680ba9..479ffcd84 100644 --- a/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift +++ b/Sources/Flare/Classes/Providers/PurchaseProvider/PurchaseProvider.swift @@ -6,6 +6,8 @@ import Foundation import StoreKit +typealias FallbackHandler = Closure> + // MARK: - PurchaseProvider final class PurchaseProvider { @@ -14,9 +16,11 @@ final class PurchaseProvider { /// The provider is responsible for making in-app payments. private let paymentProvider: IPaymentProvider /// The transaction listener. - private let transactionListener: ITransactionListener? + private var transactionListener: ITransactionListener? /// The configuration provider. private let configurationProvider: IConfigurationProvider + /// The fallback handler. + private var fallbackHandler: FallbackHandler? // MARK: Initialization @@ -37,7 +41,7 @@ final class PurchaseProvider { if let transactionListener = transactionListener { self.transactionListener = transactionListener } else if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { - self.transactionListener = TransactionListener(updates: StoreKit.Transaction.updates) + self.configureTransactionListener() } else { self.transactionListener = nil } @@ -78,12 +82,20 @@ final class PurchaseProvider { Task { switch result { case let .success(result): - if let transaction = try await self.transactionListener?.handle(purchaseResult: result) { - await completion(.success(transaction)) - Logger.info(message: L10n.Purchase.purchasedProduct(sk2StoreProduct.productIdentifier)) - } else { - await completion(.failure(IAPError.unknown)) - self.log(error: IAPError.unknown, productID: sk2StoreProduct.productIdentifier) + do { + if let transaction = try await self.transactionListener?.handle(purchaseResult: result) { + await completion(.success(transaction)) + Logger.info(message: L10n.Purchase.purchasedProduct(sk2StoreProduct.productIdentifier)) + } else { + await completion(.failure(IAPError.unknown)) + self.log(error: IAPError.unknown, productID: sk2StoreProduct.productIdentifier) + } + } catch { + if let error = error as? IAPError { + await completion(.failure(error)) + } else { + await completion(.failure(.with(error: error))) + } } case let .failure(error): await completion(.failure(IAPError(error: error))) @@ -108,6 +120,15 @@ final class PurchaseProvider { } } + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + private func configureTransactionListener() { + self.transactionListener = TransactionListener(delegate: self, updates: StoreKit.Transaction.updates) + + Task { + await self.transactionListener?.listenForTransaction() + } + } + private func log(error: Error, productID: String) { Logger.error(message: L10n.Purchase.productPurchaseFailed(productID, error.localizedDescription)) } @@ -191,11 +212,13 @@ extension PurchaseProvider: IPurchaseProvider { } } - func addTransactionObserver(fallbackHandler: Closure>?) { + func addTransactionObserver(fallbackHandler: FallbackHandler?) { + self.fallbackHandler = fallbackHandler + paymentProvider.set { _, result in switch result { case let .success(transaction): - fallbackHandler?(.success(PaymentTransaction(transaction))) + fallbackHandler?(.success(StoreTransaction(paymentTransaction: PaymentTransaction(transaction)))) case let .failure(error): fallbackHandler?(.failure(error)) } @@ -206,4 +229,22 @@ extension PurchaseProvider: IPurchaseProvider { func removeTransactionObserver() { paymentProvider.removeTransactionObserver() } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func restore() async throws { + try await AppStore.sync() + } +} + +// MARK: TransactionListenerDelegate + +extension PurchaseProvider: TransactionListenerDelegate { + func transactionListener(_: ITransactionListener, transactionDidUpdate result: Result) { + switch result { + case let .success(transaction): + self.fallbackHandler?(.success(transaction)) + case let .failure(error): + self.fallbackHandler?(.failure(error)) + } + } } diff --git a/Sources/Flare/Flare.docc/Articles/caching.md b/Sources/Flare/Flare.docc/Articles/caching.md new file mode 100644 index 000000000..5e7b7e762 --- /dev/null +++ b/Sources/Flare/Flare.docc/Articles/caching.md @@ -0,0 +1,19 @@ +# Caching Products + +Learn how to cache products. + +## Overview + +Caching products can improve the performance and user experience of your app by reducing the need to fetch product information from the App Store. In this guide, we'll explore how to cache products efficiently in your app. + +## Implementing Product Caching + +By default, Flare uses cached data if available; otherwise, it fetches the products. If you want to change this behavior, you need to configure Flare with a custom caching policy. For this, Flare provides two options ``FetchCachePolicy/fetch`` and ``FetchCachePolicy/cachedOrFetch``. + +You can override the default behaviour passing a ``FetchCachePolicy/fetch`` with a configuration. + +```swift +Flare.default.configure(with: .init(Configuration(username: "username", fetchCachePolicy: .fetch))) +``` + +This configuration tells Flare to always fetch the latest data, ignoring any cached data. You can adjust this behavior as needed for your app. diff --git a/Sources/Flare/Flare.docc/Articles/logging.md b/Sources/Flare/Flare.docc/Articles/logging.md index 6df6914d4..bd324a6ad 100644 --- a/Sources/Flare/Flare.docc/Articles/logging.md +++ b/Sources/Flare/Flare.docc/Articles/logging.md @@ -1,4 +1,4 @@ -# logging +# Logging Learn how to log important events. diff --git a/Sources/Flare/Flare.docc/Articles/perform-purchase.md b/Sources/Flare/Flare.docc/Articles/perform-purchase.md index 87dd4ea88..a2c9e4ed4 100644 --- a/Sources/Flare/Flare.docc/Articles/perform-purchase.md +++ b/Sources/Flare/Flare.docc/Articles/perform-purchase.md @@ -4,10 +4,10 @@ Learn how to perform a purchase. ## Setup Observers -> tip: This step isn't required if the app uses system higher than iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0. - The transactions array will only be synchronized with the server while the queue has observers. These methods may require that the user authenticate. It is important to set an observer on this queue as early as possible after your app launch. Observer is responsible for processing all events triggered by the queue. +The closure emits a transaction when the system creates or updates transactions that occur outside of the app or on other devices. + ```swift // Adds transaction observer to the payment queue and handles payment transactions. Flare.shared.addTransactionObserver { result in @@ -29,7 +29,7 @@ Flare.shared.removeTransactionObserver() The fetch method sends a request to the App Store, which retrieves the products if they are available. The productIDs parameter takes the product ids, which should be given from the App Store. -> important: Before attempting to add a payment always check if the user can actually make payments. The Flare does it under the hood, if a user cannot make payments, you will get an ``IAPError`` with the value ``IAPError/paymentNotAllowed``. +> important: Before attempting to add a payment always check if the user can actually make payments. The Flare does it under the hood, if a user cannot make payments, you will get an ``IAPError/paymentNotAllowed``. ```swift Flare.shared.fetch(productIDs: ["product_id"]) { result in @@ -42,7 +42,7 @@ Flare.shared.fetch(productIDs: ["product_id"]) { result in } ``` -Additionally, there are versions of both fetch that provide an `async` method, allowing the use of await. +Additionally, there is an `await` version of the ``IFlare/fetch(productIDs:)`` method. ```swift do { @@ -54,6 +54,14 @@ do { > note: Products are cached by default. If caching is not possible for specific usecases, set ``Configuration/fetchCachePolicy`` to ``FetchCachePolicy/fetch``. +```swift +import Flare + +let configuration = Configuration(fetchCachePolicy: .fetch) + +Flare.configure(with: configuration) +``` + ## Purchasing Product Flare provides a few methods to perform a purchase: @@ -93,4 +101,3 @@ Flare.shared.finish(transaction: transaction, completion: nil) ``` > important: Don’t call the ``IFlare/finish(transaction:completion:)`` method before the transaction is actually complete and attempt to use some other mechanism in your app to track the transaction as unfinished. StoreKit doesn’t function that way, and doing that prevents your app from downloading Apple-hosted content and can lead to other issues. - diff --git a/Sources/Flare/Makefile b/Sources/Flare/Makefile new file mode 100644 index 000000000..1a5286c90 --- /dev/null +++ b/Sources/Flare/Makefile @@ -0,0 +1,2 @@ +swiftgen: + swiftgen \ No newline at end of file diff --git a/swiftgen.yml b/Sources/Flare/swiftgen.yml similarity index 69% rename from swiftgen.yml rename to Sources/Flare/swiftgen.yml index d54965973..cb94fd344 100644 --- a/swiftgen.yml +++ b/Sources/Flare/swiftgen.yml @@ -1,5 +1,5 @@ -input_dir: Sources/Flare/Resources -output_dir: Sources/Flare/Classes/Generated +input_dir: Resources +output_dir: Classes/Generated strings: inputs: - Localizable.strings diff --git a/Sources/FlareMock/Fakes/StoreProduct+Fake.swift b/Sources/FlareMock/Fakes/StoreProduct+Fake.swift new file mode 100644 index 000000000..5e0d9be54 --- /dev/null +++ b/Sources/FlareMock/Fakes/StoreProduct+Fake.swift @@ -0,0 +1,39 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit + +public extension StoreProduct { + static func fake( + localizedTitle: String? = "My App Lifetime", + localizedDescription: String? = "Lifetime access to additional content", + price: Decimal? = 1.0, + currencyCode: String? = "USD", + localizedPriceString: String? = "$19.99", + productIdentifier: String? = "com.flare.app.lifetime", + productType: ProductType? = nil, + productCategory: ProductCategory? = nil, + subscriptionPeriod: SubscriptionPeriod? = nil, + introductoryDiscount: StoreProductDiscount? = nil, + discounts: [StoreProductDiscount] = [], + subscriptionGroupIdentifier: String? = nil + ) -> StoreProduct { + let mock = ProductMock() + mock.stubbedLocalizedTitle = localizedTitle + mock.stubbedLocalizedDescription = localizedDescription + mock.stubbedPrice = price + mock.stubbedCurrencyCode = currencyCode + mock.stubbedLocalizedPriceString = localizedPriceString + mock.stubbedProductIdentifier = productIdentifier + mock.stubbedProductType = productType + mock.stubbedProductCategory = productCategory + mock.stubbedSubscriptionPeriod = subscriptionPeriod + mock.stubbedIntroductoryDiscount = introductoryDiscount + mock.stubbedDiscounts = discounts + mock.stubbedSubscriptionGroupIdentifier = subscriptionGroupIdentifier + return StoreProduct(mock) + } +} diff --git a/Sources/FlareMock/Fakes/StoreTransaction+Fake.swift b/Sources/FlareMock/Fakes/StoreTransaction+Fake.swift new file mode 100644 index 000000000..6db725f7f --- /dev/null +++ b/Sources/FlareMock/Fakes/StoreTransaction+Fake.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import Foundation + +public extension StoreTransaction { + static func fake() -> StoreTransaction { + StoreTransaction(paymentTransaction: PaymentTransaction(PaymentTransactionMock())) + } +} diff --git a/Sources/FlareMock/Mocks/PaymentTransactionMock.swift b/Sources/FlareMock/Mocks/PaymentTransactionMock.swift new file mode 100644 index 000000000..9df171c64 --- /dev/null +++ b/Sources/FlareMock/Mocks/PaymentTransactionMock.swift @@ -0,0 +1,48 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import StoreKit + +public final class PaymentTransactionMock: SKPaymentTransaction { + override public init() {} + + public var invokedTransactionState = false + public var invokedTransactionStateCount = 0 + public var stubbedTransactionState: SKPaymentTransactionState! + + override public var transactionState: SKPaymentTransactionState { + stubbedTransactionState + } + + public var invokedTransactionIndentifier = false + public var invokedTransactionIndentifierCount = 0 + public var stubbedTransactionIndentifier: String? + + override public var transactionIdentifier: String? { + invokedTransactionIndentifier = true + invokedTransactionStateCount += 1 + return stubbedTransactionIndentifier + } + + public var invokedPayment = false + public var invokedPaymentCount = 0 + public var stubbedPayment: SKPayment! + + override public var payment: SKPayment { + invokedPayment = true + invokedPaymentCount += 1 + return stubbedPayment + } + + public var stubbedOriginal: SKPaymentTransaction? + override public var original: SKPaymentTransaction? { + stubbedOriginal + } + + public var stubbedError: Error? + override public var error: Error? { + stubbedError + } +} diff --git a/Sources/FlareMock/Mocks/ProductMock.swift b/Sources/FlareMock/Mocks/ProductMock.swift new file mode 100644 index 000000000..18db19af9 --- /dev/null +++ b/Sources/FlareMock/Mocks/ProductMock.swift @@ -0,0 +1,138 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import StoreKit + +public final class ProductMock: ISKProduct { + public init() {} + + public var invokedLocalizedDescriptionGetter = false + public var invokedLocalizedDescriptionGetterCount = 0 + public var stubbedLocalizedDescription: String! = "" + + public var localizedDescription: String { + invokedLocalizedDescriptionGetter = true + invokedLocalizedDescriptionGetterCount += 1 + return stubbedLocalizedDescription + } + + public var invokedLocalizedTitleGetter = false + public var invokedLocalizedTitleGetterCount = 0 + public var stubbedLocalizedTitle: String! = "" + + public var localizedTitle: String { + invokedLocalizedTitleGetter = true + invokedLocalizedTitleGetterCount += 1 + return stubbedLocalizedTitle + } + + public var invokedCurrencyCodeGetter = false + public var invokedCurrencyCodeGetterCount = 0 + public var stubbedCurrencyCode: String! + + public var currencyCode: String? { + invokedCurrencyCodeGetter = true + invokedCurrencyCodeGetterCount += 1 + return stubbedCurrencyCode + } + + public var invokedPriceGetter = false + public var invokedPriceGetterCount = 0 + public var stubbedPrice: Decimal! + + public var price: Decimal { + invokedPriceGetter = true + invokedPriceGetterCount += 1 + return stubbedPrice + } + + public var invokedLocalizedPriceStringGetter = false + public var invokedLocalizedPriceStringGetterCount = 0 + public var stubbedLocalizedPriceString: String! + + public var localizedPriceString: String? { + invokedLocalizedPriceStringGetter = true + invokedLocalizedPriceStringGetterCount += 1 + return stubbedLocalizedPriceString + } + + public var invokedProductIdentifierGetter = false + public var invokedProductIdentifierGetterCount = 0 + public var stubbedProductIdentifier: String! = "" + + public var productIdentifier: String { + invokedProductIdentifierGetter = true + invokedProductIdentifierGetterCount += 1 + return stubbedProductIdentifier + } + + public var invokedProductTypeGetter = false + public var invokedProductTypeGetterCount = 0 + public var stubbedProductType: ProductType! + + public var productType: ProductType? { + invokedProductTypeGetter = true + invokedProductTypeGetterCount += 1 + return stubbedProductType + } + + public var invokedProductCategoryGetter = false + public var invokedProductCategoryGetterCount = 0 + public var stubbedProductCategory: ProductCategory! + + public var productCategory: ProductCategory? { + invokedProductCategoryGetter = true + invokedProductCategoryGetterCount += 1 + return stubbedProductCategory + } + + public var invokedSubscriptionPeriodGetter = false + public var invokedSubscriptionPeriodGetterCount = 0 + public var stubbedSubscriptionPeriod: SubscriptionPeriod! + + public var subscriptionPeriod: SubscriptionPeriod? { + invokedSubscriptionPeriodGetter = true + invokedSubscriptionPeriodGetterCount += 1 + return stubbedSubscriptionPeriod + } + + public var invokedIntroductoryDiscountGetter = false + public var invokedIntroductoryDiscountGetterCount = 0 + public var stubbedIntroductoryDiscount: StoreProductDiscount! + + public var introductoryDiscount: StoreProductDiscount? { + invokedIntroductoryDiscountGetter = true + invokedIntroductoryDiscountGetterCount += 1 + return stubbedIntroductoryDiscount + } + + public var invokedDiscountsGetter = false + public var invokedDiscountsGetterCount = 0 + public var stubbedDiscounts: [StoreProductDiscount]! = [] + + public var discounts: [StoreProductDiscount] { + invokedDiscountsGetter = true + invokedDiscountsGetterCount += 1 + return stubbedDiscounts + } + + // swiftlint:disable identifier_name + public var invokedSubscriptionGroupIdentifierGetter = false + public var invokedSubscriptionGroupIdentifierGetterCount = 0 + public var stubbedSubscriptionGroupIdentifier: String! + + public var subscriptionGroupIdentifier: String? { + invokedSubscriptionGroupIdentifierGetter = true + invokedSubscriptionGroupIdentifierGetterCount += 1 + return stubbedSubscriptionGroupIdentifier + } + + // swiftlint:enable identifier_name + + public var subscription: SubscriptionInfo? { + nil + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/AnyProductStyle.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/AnyProductStyle.swift new file mode 100644 index 000000000..75a9fb081 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/AnyProductStyle.swift @@ -0,0 +1,31 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +struct AnyProductStyle: IProductStyle { + // MARK: Properties + + /// A private property to hold the closure that creates the body of the view + private var _makeBody: (Configuration) -> AnyView + + // MARK: Initialization + + /// Initializes the `AnyProductStyle` with a specific style conforming to `IProductStyle`. + /// + /// - Parameter style: A product style. + init(style: S) { + _makeBody = { configuration in + AnyView(style.makeBody(configuration: configuration)) + } + } + + // MARK: IProductStyle + + /// Implements the makeBody method required by `IProductStyle`. + func makeBody(configuration: Configuration) -> some View { + _makeBody(configuration) + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/AnySubscriptionControlStyle.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/AnySubscriptionControlStyle.swift new file mode 100644 index 000000000..f12f252ef --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/AnySubscriptionControlStyle.swift @@ -0,0 +1,34 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +struct AnySubscriptionControlStyle: ISubscriptionControlStyle { + // MARK: Properties + + let style: any ISubscriptionControlStyle + + /// A private property to hold the closure that creates the body of the view + private var _makeBody: (Configuration) -> AnyView + + // MARK: Initialization + + /// Initializes the `AnyProductStyle` with a specific style conforming to `IProductStyle`. + /// + /// - Parameter style: A product style. + init(style: S) { + self.style = style + _makeBody = { configuration in + AnyView(style.makeBody(configuration: configuration)) + } + } + + // MARK: IProductStyle + + /// Implements the makeBody method required by `IProductStyle`. + func makeBody(configuration: Configuration) -> some View { + _makeBody(configuration) + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/Assemblies/ProductAssemblyKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/Assemblies/ProductAssemblyKey.swift new file mode 100644 index 000000000..332829c4a --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/Assemblies/ProductAssemblyKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - ProductAssemblyKey + +private struct ProductAssemblyKey: EnvironmentKey { + static var defaultValue: IProductViewAssembly? +} + +extension EnvironmentValues { + var productViewAssembly: IProductViewAssembly? { + get { self[ProductAssemblyKey.self] } + set { self[ProductAssemblyKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/Assemblies/StoreButtonsAssemblyKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/Assemblies/StoreButtonsAssemblyKey.swift new file mode 100644 index 000000000..63bd28d7f --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/Assemblies/StoreButtonsAssemblyKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - StoreButtonsAssemblyKey + +private struct StoreButtonsAssemblyKey: EnvironmentKey { + static var defaultValue: IStoreButtonsAssembly? +} + +extension EnvironmentValues { + var storeButtonsAssembly: IStoreButtonsAssembly? { + get { self[StoreButtonsAssemblyKey.self] } + set { self[StoreButtonsAssemblyKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/BlurEffectStyleKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/BlurEffectStyleKey.swift new file mode 100644 index 000000000..5e96dd9c0 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/BlurEffectStyleKey.swift @@ -0,0 +1,21 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - BlurEffectStyleKey + +#if os(iOS) || os(tvOS) + private struct BlurEffectStyleKey: EnvironmentKey { + static var defaultValue: UIBlurEffect.Style = .light + } + + extension EnvironmentValues { + var blurEffectStyle: UIBlurEffect.Style { + get { self[BlurEffectStyleKey.self] } + set { self[BlurEffectStyleKey.self] = newValue } + } + } +#endif diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/PoliciesButtonStyleKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/PoliciesButtonStyleKey.swift new file mode 100644 index 000000000..ecdca5c55 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/PoliciesButtonStyleKey.swift @@ -0,0 +1,21 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - PoliciesButtonStyleKey + +@available(watchOS, unavailable) +private struct PoliciesButtonStyleKey: EnvironmentKey { + static var defaultValue: AnyPoliciesButtonStyle = .init(style: AutomaticPoliciesButtonStyle()) +} + +@available(watchOS, unavailable) +extension EnvironmentValues { + var policiesButtonStyle: AnyPoliciesButtonStyle { + get { self[PoliciesButtonStyleKey.self] } + set { self[PoliciesButtonStyleKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/ProductStyleKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/ProductStyleKey.swift new file mode 100644 index 000000000..b466c0858 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/ProductStyleKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - ProductStyleKey + +private struct ProductStyleKey: EnvironmentKey { + static var defaultValue = AnyProductStyle(style: CompactProductStyle()) +} + +extension EnvironmentValues { + var productViewStyle: AnyProductStyle { + get { self[ProductStyleKey.self] } + set { self[ProductStyleKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/PurchaseCompletionKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/PurchaseCompletionKey.swift new file mode 100644 index 000000000..617e2bb74 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/PurchaseCompletionKey.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import SwiftUI + +public typealias PurchaseCompletionHandler = (StoreProduct, Result) -> Void + +// MARK: - PurchaseCompletionKey + +private struct PurchaseCompletionKey: EnvironmentKey { + static var defaultValue: PurchaseCompletionHandler? +} + +extension EnvironmentValues { + var purchaseCompletion: PurchaseCompletionHandler? { + get { self[PurchaseCompletionKey.self] } + set { self[PurchaseCompletionKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/PurchaseOptionKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/PurchaseOptionKey.swift new file mode 100644 index 000000000..44d2f7a36 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/PurchaseOptionKey.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import StoreKit +import SwiftUI + +typealias PurchaseOptionHandler = (StoreProduct) -> PurchaseOptions + +// MARK: - PurchaseOptionKey + +private struct PurchaseOptionKey: EnvironmentKey { + static var defaultValue: PurchaseOptionHandler? +} + +extension EnvironmentValues { + var purchaseOptions: PurchaseOptionHandler? { + get { self[PurchaseOptionKey.self] } + set { self[PurchaseOptionKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/StoreButtonKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/StoreButtonKey.swift new file mode 100644 index 000000000..071693cb6 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/StoreButtonKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - StoreButtonKey + +private struct StoreButtonKey: EnvironmentKey { + static var defaultValue: [StoreButtonType] = [] +} + +extension EnvironmentValues { + var storeButton: [StoreButtonType] { + get { self[StoreButtonKey.self] } + set { self[StoreButtonKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/StoreButtonViewFontWeightKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/StoreButtonViewFontWeightKey.swift new file mode 100644 index 000000000..a351eaba6 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/StoreButtonViewFontWeightKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - StoreButtonViewFontWeightKey + +private struct StoreButtonViewFontWeightKey: EnvironmentKey { + static var defaultValue: Font.Weight = .regular +} + +extension EnvironmentValues { + var storeButtonViewFontWeight: Font.Weight { + get { self[StoreButtonViewFontWeightKey.self] } + set { self[StoreButtonViewFontWeightKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionBackgroundKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionBackgroundKey.swift new file mode 100644 index 000000000..c48ccfe0b --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionBackgroundKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionBackgroundKey + +private struct SubscriptionBackgroundKey: EnvironmentKey { + static var defaultValue: Color = Palette.systemBackground +} + +extension EnvironmentValues { + var subscriptionBackground: Color { + get { self[SubscriptionBackgroundKey.self] } + set { self[SubscriptionBackgroundKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionControlStyleKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionControlStyleKey.swift new file mode 100644 index 000000000..01df8f43b --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionControlStyleKey.swift @@ -0,0 +1,21 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionControlStyleKey + +@available(watchOS, unavailable) +private struct SubscriptionControlStyleKey: EnvironmentKey { + static var defaultValue: AnySubscriptionControlStyle = .init(style: .automatic) +} + +@available(watchOS, unavailable) +extension EnvironmentValues { + var subscriptionControlStyle: AnySubscriptionControlStyle { + get { self[SubscriptionControlStyleKey.self] } + set { self[SubscriptionControlStyleKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionHeaderContentBackgroundKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionHeaderContentBackgroundKey.swift new file mode 100644 index 000000000..3ba5ccfea --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionHeaderContentBackgroundKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionHeaderContentBackgroundKey + +private struct SubscriptionHeaderContentBackgroundKey: EnvironmentKey { + static var defaultValue: Color = .clear +} + +extension EnvironmentValues { + var subscriptionHeaderContentBackground: Color { + get { self[SubscriptionHeaderContentBackgroundKey.self] } + set { self[SubscriptionHeaderContentBackgroundKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionMarketingContentKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionMarketingContentKey.swift new file mode 100644 index 000000000..f7169052f --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionMarketingContentKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionMarketingContentKey + +private struct SubscriptionMarketingContentKey: EnvironmentKey { + static var defaultValue: AnyView? +} + +extension EnvironmentValues { + var subscriptionMarketingContent: AnyView? { + get { self[SubscriptionMarketingContentKey.self] } + set { self[SubscriptionMarketingContentKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionPickerItemBackgroundKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionPickerItemBackgroundKey.swift new file mode 100644 index 000000000..a2ea62a28 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionPickerItemBackgroundKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionPickerItemBackgroundKey + +private struct SubscriptionPickerItemBackgroundKey: EnvironmentKey { + static var defaultValue: Color = Palette.systemGray5 +} + +extension EnvironmentValues { + var subscriptionPickerItemBackground: Color { + get { self[SubscriptionPickerItemBackgroundKey.self] } + set { self[SubscriptionPickerItemBackgroundKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionPrivacyPolicyDestinationKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionPrivacyPolicyDestinationKey.swift new file mode 100644 index 000000000..0d3adf7ab --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionPrivacyPolicyDestinationKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionPrivacyPolicyDestinationKey + +private struct SubscriptionPrivacyPolicyDestinationKey: EnvironmentKey { + static var defaultValue: AnyView? +} + +extension EnvironmentValues { + var subscriptionPrivacyPolicyDestination: AnyView? { + get { self[SubscriptionPrivacyPolicyDestinationKey.self] } + set { self[SubscriptionPrivacyPolicyDestinationKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionPrivacyPolicyURLKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionPrivacyPolicyURLKey.swift new file mode 100644 index 000000000..08d40b901 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionPrivacyPolicyURLKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionPrivacyPolicyURLKey + +private struct SubscriptionPrivacyPolicyURLKey: EnvironmentKey { + static var defaultValue: URL? +} + +extension EnvironmentValues { + var subscriptionPrivacyPolicyURL: URL? { + get { self[SubscriptionPrivacyPolicyURLKey.self] } + set { self[SubscriptionPrivacyPolicyURLKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionStoreButtonLabelKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionStoreButtonLabelKey.swift new file mode 100644 index 000000000..4b7019641 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionStoreButtonLabelKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionStoreButtonLabelKey + +private struct SubscriptionStoreButtonLabelKey: EnvironmentKey { + static var defaultValue: SubscriptionStoreButtonLabel = .action +} + +extension EnvironmentValues { + var subscriptionStoreButtonLabel: SubscriptionStoreButtonLabel { + get { self[SubscriptionStoreButtonLabelKey.self] } + set { self[SubscriptionStoreButtonLabelKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionTermsOfServiceDestinationKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionTermsOfServiceDestinationKey.swift new file mode 100644 index 000000000..a581e3647 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionTermsOfServiceDestinationKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionTermsOfServiceDestinationKey + +private struct SubscriptionTermsOfServiceDestinationKey: EnvironmentKey { + static var defaultValue: AnyView? +} + +extension EnvironmentValues { + var subscriptionTermsOfServiceDestination: AnyView? { + get { self[SubscriptionTermsOfServiceDestinationKey.self] } + set { self[SubscriptionTermsOfServiceDestinationKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionTermsOfServiceURLKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionTermsOfServiceURLKey.swift new file mode 100644 index 000000000..af1d57532 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionTermsOfServiceURLKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionTermsOfServiceURLKey + +private struct SubscriptionTermsOfServiceURLKey: EnvironmentKey { + static var defaultValue: URL? +} + +extension EnvironmentValues { + var subscriptionTermsOfServiceURL: URL? { + get { self[SubscriptionTermsOfServiceURLKey.self] } + set { self[SubscriptionTermsOfServiceURLKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionViewTintKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionViewTintKey.swift new file mode 100644 index 000000000..ff2f8b2ab --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionViewTintKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionViewTintKey + +private struct SubscriptionViewTintKey: EnvironmentKey { + static var defaultValue: Color = .blue +} + +extension EnvironmentValues { + var subscriptionViewTint: Color { + get { self[SubscriptionViewTintKey.self] } + set { self[SubscriptionViewTintKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionsWrapperViewStyleKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionsWrapperViewStyleKey.swift new file mode 100644 index 000000000..65d284e5d --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/SubscriptionsWrapperViewStyleKey.swift @@ -0,0 +1,21 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionsWrapperViewStyleKey + +@available(watchOS, unavailable) +private struct SubscriptionsWrapperViewStyleKey: EnvironmentKey { + static var defaultValue = AnySubscriptionsWrapperViewStyle(style: AutomaticSubscriptionsWrapperViewStyle()) +} + +@available(watchOS, unavailable) +extension EnvironmentValues { + var subscriptionsWrapperViewStyle: AnySubscriptionsWrapperViewStyle { + get { self[SubscriptionsWrapperViewStyleKey.self] } + set { self[SubscriptionsWrapperViewStyleKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/EnvironmentKey/TintColorKey.swift b/Sources/FlareUI/Classes/Core/EnvironmentKey/TintColorKey.swift new file mode 100644 index 000000000..341a0e0dd --- /dev/null +++ b/Sources/FlareUI/Classes/Core/EnvironmentKey/TintColorKey.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - TintColorKey + +private struct TintColorKey: EnvironmentKey { + static var defaultValue: Color = .blue +} + +extension EnvironmentValues { + var tintColor: Color { + get { self[TintColorKey.self] } + set { self[TintColorKey.self] = newValue } + } +} diff --git a/Sources/FlareUI/Classes/Core/Extensions/Array+RemoveDuplicates.swift b/Sources/FlareUI/Classes/Core/Extensions/Array+RemoveDuplicates.swift new file mode 100644 index 000000000..db8c8ad86 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Extensions/Array+RemoveDuplicates.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +extension Array where Element: Hashable { + func removingDuplicates() -> [Element] { + var set = Set() + return filter { set.insert($0).inserted } + } +} diff --git a/Sources/FlareUI/Classes/Core/Extensions/String+SubSequence.swift b/Sources/FlareUI/Classes/Core/Extensions/String+SubSequence.swift new file mode 100644 index 000000000..6a579abe0 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Extensions/String+SubSequence.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +extension String { + init?(_ substring: SubSequence?) { + guard let substring else { return nil } + self.init(substring) + } +} diff --git a/Sources/FlareUI/Classes/Core/Extensions/StringProtocol+Words.swift b/Sources/FlareUI/Classes/Core/Extensions/StringProtocol+Words.swift new file mode 100644 index 000000000..6b7040d0e --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Extensions/StringProtocol+Words.swift @@ -0,0 +1,16 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +extension StringProtocol { + var words: [SubSequence] { + var byWords: [SubSequence] = [] + enumerateSubstrings(in: startIndex..., options: .byWords) { _, range, _, _ in + byWords.append(self[range]) + } + return byWords + } +} diff --git a/Sources/FlareUI/Classes/Core/Extensions/View+EraseToAnyView.swift b/Sources/FlareUI/Classes/Core/Extensions/View+EraseToAnyView.swift new file mode 100644 index 000000000..544f5e657 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Extensions/View+EraseToAnyView.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +extension View { + func eraseToAnyView() -> AnyView { + AnyView(self) + } +} diff --git a/Sources/FlareUI/Classes/Core/Formatters/DateComponentsFormatter+Full.swift b/Sources/FlareUI/Classes/Core/Formatters/DateComponentsFormatter+Full.swift new file mode 100644 index 000000000..c4429b527 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Formatters/DateComponentsFormatter+Full.swift @@ -0,0 +1,16 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +extension DateComponentsFormatter { + static let full: IDateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.maximumUnitCount = 1 + formatter.unitsStyle = .full + formatter.zeroFormattingBehavior = .dropAll + return formatter + }() +} diff --git a/Sources/FlareUI/Classes/Core/Formatters/IDateComponentsFormatter.swift b/Sources/FlareUI/Classes/Core/Formatters/IDateComponentsFormatter.swift new file mode 100644 index 000000000..041d990fc --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Formatters/IDateComponentsFormatter.swift @@ -0,0 +1,25 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - IDateComponentsFormatter + +/// A type that creates string representations of quantities of time. +protocol IDateComponentsFormatter { + /// The bitmask of calendrical units such as day and month to include in the output string. + var allowedUnits: NSCalendar.Unit { get set } + + /// Returns a formatted string based on the specified date component information. + /// + /// - Parameter from: A date components object containing the date and time information to format. + /// + /// - Returns: A formatted string representing the specified date information. + func string(from: DateComponents) -> String? +} + +// MARK: - DateComponentsFormatter + IDateComponentsFormatter + +extension DateComponentsFormatter: IDateComponentsFormatter {} diff --git a/Sources/FlareUI/Classes/Core/Helpers/Array+StoreProduct.swift b/Sources/FlareUI/Classes/Core/Helpers/Array+StoreProduct.swift new file mode 100644 index 000000000..c3931f980 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Helpers/Array+StoreProduct.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +// MARK: - Extensions + +extension Array where Element: StoreProduct { + func by(id: String) -> StoreProduct? { + first(where: { $0.productIdentifier == id }) + } +} diff --git a/Sources/FlareUI/Classes/Core/Helpers/Color+UIColor.swift b/Sources/FlareUI/Classes/Core/Helpers/Color+UIColor.swift new file mode 100644 index 000000000..4441603fd --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Helpers/Color+UIColor.swift @@ -0,0 +1,36 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +#if os(iOS) || os(tvOS) + typealias UIColor = UIKit.UIColor +#elseif os(macOS) + typealias UIColor = NSColor +#endif + +// swiftlint:disable identifier_name +extension Color { + func uiColor() -> UIColor { + if #available(iOS 14.0, tvOS 14.0, macOS 11.0, *) { + return UIColor(self) + } + + let scanner = Scanner(string: description.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)) + var hexNumber: UInt64 = 0 + var r: CGFloat = 0.0, g: CGFloat = 0.0, b: CGFloat = 0.0, a: CGFloat = 0.0 + + let result = scanner.scanHexInt64(&hexNumber) + if result { + r = CGFloat((hexNumber & 0xFF00_0000) >> 24) / 255 + g = CGFloat((hexNumber & 0x00FF_0000) >> 16) / 255 + b = CGFloat((hexNumber & 0x0000_FF00) >> 8) / 255 + a = CGFloat(hexNumber & 0x0000_00FF) / 255 + } + return UIColor(red: r, green: g, blue: b, alpha: a) + } +} + +// swiftlint:enable identifier_name diff --git a/Sources/FlareUI/Classes/Core/Helpers/Error+IAP.swift b/Sources/FlareUI/Classes/Core/Helpers/Error+IAP.swift new file mode 100644 index 000000000..534b1355d --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Helpers/Error+IAP.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +extension Error { + var iap: IAPError { + if let error = self as? IAPError { + return error + } + return .with(error: self) + } +} diff --git a/Sources/FlareUI/Classes/Core/Helpers/Value.swift b/Sources/FlareUI/Classes/Core/Helpers/Value.swift new file mode 100644 index 000000000..78026fa2e --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Helpers/Value.swift @@ -0,0 +1,20 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +func value(default: T, tvOS: T? = nil, macOS: T? = nil, iOS: T? = nil, watchOS: T? = nil) -> T { + #if os(iOS) + return iOS ?? `default` + #elseif os(macOS) + return macOS ?? `default` + #elseif os(tvOS) + return tvOS ?? `default` + #elseif os(watchOS) + return watchOS ?? `default` + #else + return `default` + #endif +} diff --git a/Sources/FlareUI/Classes/Core/Models/Internal/PriceDisplayFormat.swift b/Sources/FlareUI/Classes/Core/Models/Internal/PriceDisplayFormat.swift new file mode 100644 index 000000000..efd6e4372 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/Internal/PriceDisplayFormat.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Enum representing different formats for displaying prices. +enum PriceDisplayFormat { + /// Short format for displaying prices. + case short + + /// Full format for displaying prices. + case full +} diff --git a/Sources/FlareUI/Classes/Core/Models/Internal/ProductStyle.swift b/Sources/FlareUI/Classes/Core/Models/Internal/ProductStyle.swift new file mode 100644 index 000000000..8b80fb970 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/Internal/ProductStyle.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Enum representing different styles for displaying a product. +enum ProductStyle { + /// Compact style for displaying a product. + case compact + + /// Large style for displaying a product. + case large +} diff --git a/Sources/FlareUI/Classes/Core/Models/PaywallType.swift b/Sources/FlareUI/Classes/Core/Models/PaywallType.swift new file mode 100644 index 000000000..ddd6bc39a --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/PaywallType.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// An enum represents a paywall type. +public enum PaywallType { + /// Represents a paywall for subscriptions. + case subscriptions(type: any Collection) + + /// Represents a paywall for specific products identified by their IDs. + case products(productIDs: any Collection) +} diff --git a/Sources/FlareUI/Classes/Core/Models/PurchaseOptions.swift b/Sources/FlareUI/Classes/Core/Models/PurchaseOptions.swift new file mode 100644 index 000000000..38f8a8442 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/PurchaseOptions.swift @@ -0,0 +1,30 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import StoreKit + +/// Struct representing purchase options for a product. +struct PurchaseOptions { + // MARK: Properties + + /// Internal storage for purchase options. + private var _options: Any? + + /// Purchase options as a set of `Product.PurchaseOption`. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + var options: Set? { + _options as? Set + } + + // MARK: Initialization + + /// Initializes the purchase options with the given set of options. + /// + /// - Parameter options: The set of purchase options to store. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + init(options: Set) { + self._options = options + } +} diff --git a/Sources/FlareUI/Classes/Core/Models/SubscriptionStatusVerifierType.swift b/Sources/FlareUI/Classes/Core/Models/SubscriptionStatusVerifierType.swift new file mode 100644 index 000000000..e0c64972e --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/SubscriptionStatusVerifierType.swift @@ -0,0 +1,16 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Enum representing different types of subscription status verifiers. +public enum SubscriptionStatusVerifierType { + /// Verifier that automatically checks the subscription status. + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + case automatic + + /// Custom verifier implementing `ISubscriptionStatusVerifier` protocol. + case custom(ISubscriptionStatusVerifier) +} diff --git a/Sources/FlareUI/Classes/Core/Models/SubscriptionStoreButtonLabel.swift b/Sources/FlareUI/Classes/Core/Models/SubscriptionStoreButtonLabel.swift new file mode 100644 index 000000000..71a6cb842 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/SubscriptionStoreButtonLabel.swift @@ -0,0 +1,21 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Enum representing the labels for subscription store buttons. +public enum SubscriptionStoreButtonLabel { + /// Multiline label for the button. + case multiline + + /// Label displaying the price for the subscription. + case price + + /// Label indicating the action of the button (e.g., "Subscribe"). + case action + + /// Label displaying the display name of the subscription. + case displayName +} diff --git a/Sources/FlareUI/Classes/Core/Models/UIConfiguration.swift b/Sources/FlareUI/Classes/Core/Models/UIConfiguration.swift new file mode 100644 index 000000000..dc67ca128 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/UIConfiguration.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Struct representing configuration settings for the UI. +public struct UIConfiguration { + // MARK: Properties + + /// The subscription status verifier type to use. + public let subscriptionVerifier: SubscriptionStatusVerifierType + + // MARK: Initialization + + /// Initializes the UI configuration with the given subscription status verifier type. + /// + /// - Parameter subscriptionVerifier: The subscription status verifier type to use. + public init(subscriptionVerifier: SubscriptionStatusVerifierType) { + self.subscriptionVerifier = subscriptionVerifier + } +} diff --git a/Sources/FlareUI/Classes/Core/Providers/ConfigurationProvider/ConfigurationProvider.swift b/Sources/FlareUI/Classes/Core/Providers/ConfigurationProvider/ConfigurationProvider.swift new file mode 100644 index 000000000..6f7154299 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Providers/ConfigurationProvider/ConfigurationProvider.swift @@ -0,0 +1,34 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - ConfigurationProvider + +/// A class responsible for providing configuration settings. +final class ConfigurationProvider { + // MARK: Properties + + /// The configuration for the UI module. + private var configuration: UIConfiguration? +} + +// MARK: IConfigurationProvider + +extension ConfigurationProvider: IConfigurationProvider { + var subscriptionVerifierType: SubscriptionStatusVerifierType? { + guard let configuration else { + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + return .automatic + } + return nil + } + return configuration.subscriptionVerifier + } + + func configure(with configuration: UIConfiguration) { + self.configuration = configuration + } +} diff --git a/Sources/FlareUI/Classes/Core/Providers/ConfigurationProvider/IConfigurationProvider.swift b/Sources/FlareUI/Classes/Core/Providers/ConfigurationProvider/IConfigurationProvider.swift new file mode 100644 index 000000000..5cb1550b4 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Providers/ConfigurationProvider/IConfigurationProvider.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Type for providing configuration settings to an application. +protocol IConfigurationProvider { + /// The subscription verifier type. + var subscriptionVerifierType: SubscriptionStatusVerifierType? { get } + + /// Configures the application with the provided UI configuration. + /// + /// - Parameter configuration: The UI configuration to apply. + func configure(with configuration: UIConfiguration) +} diff --git a/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusProvider/ISubscriptionStatusVerifierProvider.swift b/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusProvider/ISubscriptionStatusVerifierProvider.swift new file mode 100644 index 000000000..103cf5308 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusProvider/ISubscriptionStatusVerifierProvider.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Protocol for providing an object that can verify subscription status. +protocol ISubscriptionStatusVerifierProvider { + /// The subscription status verifier object. + var subscriptionStatusVerifier: ISubscriptionStatusVerifier? { get } +} diff --git a/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusProvider/SubscriptionStatusVerifierProvider.swift b/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusProvider/SubscriptionStatusVerifierProvider.swift new file mode 100644 index 000000000..16808a69d --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusProvider/SubscriptionStatusVerifierProvider.swift @@ -0,0 +1,43 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - SubscriptionStatusVerifierProvider + +/// A class responsible for providing an object that can verify subscription status. +final class SubscriptionStatusVerifierProvider { + // MARK: Properties + + /// The configuration provider for obtaining the subscription verifier type. + private let configurationProvider: IConfigurationProvider + + /// The resolver for obtaining the subscription status verifier based on the type. + private let subscriptionStatusVerifierResolver: ISubscriptionStatusVerifierTypeResolver + + // MARK: Initialization + + /// Initializes the provider with the given configuration provider and resolver. + /// + /// - Parameters: + /// - configurationProvider: The configuration provider. + /// - subscriptionStatusVerifierResolver: The resolver for obtaining the verifier. + init( + configurationProvider: IConfigurationProvider, + subscriptionStatusVerifierResolver: ISubscriptionStatusVerifierTypeResolver + ) { + self.configurationProvider = configurationProvider + self.subscriptionStatusVerifierResolver = subscriptionStatusVerifierResolver + } +} + +// MARK: ISubscriptionStatusVerifierProvider + +extension SubscriptionStatusVerifierProvider: ISubscriptionStatusVerifierProvider { + var subscriptionStatusVerifier: ISubscriptionStatusVerifier? { + guard let type = configurationProvider.subscriptionVerifierType else { return nil } + return subscriptionStatusVerifierResolver.resolve(type) + } +} diff --git a/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusVerifier/ISubscriptionStatusVerifier.swift b/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusVerifier/ISubscriptionStatusVerifier.swift new file mode 100644 index 000000000..c06f175f3 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusVerifier/ISubscriptionStatusVerifier.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +/// Protocol for verifying the subscription status of a store product. +public protocol ISubscriptionStatusVerifier { + /// Asynchronously validates the subscription status of the given store product. + /// + /// - Parameters: + /// - storeProduct: The store product to validate. + /// - Returns: A boolean value indicating whether the subscription is valid. + /// - Throws: An error if the validation fails. + func validate(_ storeProduct: StoreProduct) async throws -> Bool +} diff --git a/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusVerifier/SubscriptionStatusVerifier.swift b/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusVerifier/SubscriptionStatusVerifier.swift new file mode 100644 index 000000000..c311aa842 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusVerifier/SubscriptionStatusVerifier.swift @@ -0,0 +1,37 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +/// A class responsible for verifying the subscription status of a store product. +/// +/// This class conforms to the `ISubscriptionStatusVerifier` protocol. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +final class SubscriptionStatusVerifier: ISubscriptionStatusVerifier { + // MARK: ISubscriptionStatusVerifier + + /// Asynchronously validates the subscription status of the given store product. + /// + /// - Parameter storeProduct: The store product to validate. + /// - Returns: A boolean value indicating whether the subscription is valid. + /// - Throws: An error if the validation fails. + func validate(_ storeProduct: StoreProduct) async throws -> Bool { + guard let subscription = storeProduct.subscription else { return false } + + let statuses = try await subscription.subscriptionStatus + + for status in statuses { + if case let .verified(subscription) = status.subscriptionRenewalInfo, + subscription.currentProductID == storeProduct.productIdentifier + { + if status.renewalState == .subscribed { + return true + } + } + } + + return false + } +} diff --git a/Sources/FlareUI/Classes/Core/Resolvers/SubscriptionStatusVerifierTypeResolver/ISubscriptionStatusVerifierTypeResolver.swift b/Sources/FlareUI/Classes/Core/Resolvers/SubscriptionStatusVerifierTypeResolver/ISubscriptionStatusVerifierTypeResolver.swift new file mode 100644 index 000000000..0dc2f282c --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Resolvers/SubscriptionStatusVerifierTypeResolver/ISubscriptionStatusVerifierTypeResolver.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Protocol for resolving an object that can verify subscription status based on a given type. +protocol ISubscriptionStatusVerifierTypeResolver { + /// Resolves an object that can verify subscription status based on the given type. + /// + /// - Parameter type: The type of subscription status verifier to resolve. + /// - Returns: An object that can verify subscription status, or `nil` if not found. + func resolve(_ type: SubscriptionStatusVerifierType) -> ISubscriptionStatusVerifier? +} diff --git a/Sources/FlareUI/Classes/Core/Resolvers/SubscriptionStatusVerifierTypeResolver/SubscriptionStatusVerifierTypeResolver.swift b/Sources/FlareUI/Classes/Core/Resolvers/SubscriptionStatusVerifierTypeResolver/SubscriptionStatusVerifierTypeResolver.swift new file mode 100644 index 000000000..e46ddc325 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Resolvers/SubscriptionStatusVerifierTypeResolver/SubscriptionStatusVerifierTypeResolver.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// A class responsible for resolving an object that can verify subscription status based on a given type. +final class SubscriptionStatusVerifierTypeResolver: ISubscriptionStatusVerifierTypeResolver { + // MARK: ISubscriptionStatusVerifierTypeResolver + + func resolve(_ type: SubscriptionStatusVerifierType) -> (any ISubscriptionStatusVerifier)? { + switch type { + case .automatic: + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + return SubscriptionStatusVerifier() + } + return nil + case let .custom(subscriptionVerifier): + return subscriptionVerifier + } + } +} diff --git a/Sources/FlareUI/Classes/DI/Dependencies/FlareDependencies.swift b/Sources/FlareUI/Classes/DI/Dependencies/FlareDependencies.swift new file mode 100644 index 000000000..bab6bcfd9 --- /dev/null +++ b/Sources/FlareUI/Classes/DI/Dependencies/FlareDependencies.swift @@ -0,0 +1,29 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +final class FlareDependencies: IFlareDependencies { + // MARK: IFlareDependencies + + lazy var configurationProvider: IConfigurationProvider = ConfigurationProvider() + + var iap: IFlare { + Flare.shared + } + + var subscriptionStatusVerifierProvider: ISubscriptionStatusVerifierProvider { + SubscriptionStatusVerifierProvider( + configurationProvider: self.configurationProvider, + subscriptionStatusVerifierResolver: self.subscriptionStatusVerifierResolver + ) + } + + // MARK: Private + + private var subscriptionStatusVerifierResolver: ISubscriptionStatusVerifierTypeResolver { + SubscriptionStatusVerifierTypeResolver() + } +} diff --git a/Sources/FlareUI/Classes/DI/Dependencies/IFlareDependencies.swift b/Sources/FlareUI/Classes/DI/Dependencies/IFlareDependencies.swift new file mode 100644 index 000000000..4a9ed7a9e --- /dev/null +++ b/Sources/FlareUI/Classes/DI/Dependencies/IFlareDependencies.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +/// A type defines dependencies for the package. +protocol IFlareDependencies { + /// An IAP manager. + var iap: IFlare { get } + + /// The configuration provider for FlareUI. + var configurationProvider: IConfigurationProvider { get } + + /// The custom subscription verifier. + var subscriptionStatusVerifierProvider: ISubscriptionStatusVerifierProvider { get } +} diff --git a/Sources/FlareUI/Classes/DI/PresentationAssembly/IPresentationAssembly.swift b/Sources/FlareUI/Classes/DI/PresentationAssembly/IPresentationAssembly.swift new file mode 100644 index 000000000..11b81c5ca --- /dev/null +++ b/Sources/FlareUI/Classes/DI/PresentationAssembly/IPresentationAssembly.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// A type defines a presentation assembly. +protocol IPresentationAssembly { + /// A products view assembly. + var productsViewAssembly: IProductsViewAssembly { get } + + /// A subscriptions view assembly. + var subscritpionsViewAssembly: ISubscriptionsAssembly { get } + + /// A product view assembly. + var productViewAssembly: IProductViewAssembly { get } +} diff --git a/Sources/FlareUI/Classes/DI/PresentationAssembly/PresentationAssembly.swift b/Sources/FlareUI/Classes/DI/PresentationAssembly/PresentationAssembly.swift new file mode 100644 index 000000000..1f6df3549 --- /dev/null +++ b/Sources/FlareUI/Classes/DI/PresentationAssembly/PresentationAssembly.swift @@ -0,0 +1,58 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +@available(watchOS, unavailable) +final class PresentationAssembly: IPresentationAssembly { + // MARK: Properties + + private let dependencies: IFlareDependencies + + // MARK: Initialization + + init(dependencies: IFlareDependencies = FlareDependencies()) { + self.dependencies = dependencies + } + + // MARK: IPresentationAssembly + + var productsViewAssembly: IProductsViewAssembly { + ProductsViewAssembly( + productAssembly: productViewAssembly, + storeButtonsAssembly: storeButtonsAssembly, + iap: dependencies.iap + ) + } + + var productViewAssembly: IProductViewAssembly { + ProductViewAssembly(iap: dependencies.iap) + } + + var subscritpionsViewAssembly: ISubscriptionsAssembly { + SubscriptionsAssembly( + iap: dependencies.iap, + storeButtonsAssembly: storeButtonsAssembly, + subscriptionStatusVerifierProvider: dependencies.subscriptionStatusVerifierProvider + ) + } + + // MARK: Private + + private var storeButtonAssembly: IStoreButtonAssembly { + StoreButtonAssembly(iap: dependencies.iap) + } + + private var storeButtonsAssembly: IStoreButtonsAssembly { + StoreButtonsAssembly( + storeButtonAssembly: storeButtonAssembly, + policiesButtonAssembly: policiesButtonAssembly + ) + } + + private var policiesButtonAssembly: IPoliciesButtonAssembly { + PoliciesButtonAssembly() + } +} diff --git a/Sources/FlareUI/Classes/FlareUI.swift b/Sources/FlareUI/Classes/FlareUI.swift new file mode 100644 index 000000000..4289740d7 --- /dev/null +++ b/Sources/FlareUI/Classes/FlareUI.swift @@ -0,0 +1,43 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// The class provides a way to configure the UI module. +public final class FlareUI: IFlareUI { + // MARK: Properties + + /// The dependencies for FlareUI. + private let dependencies: IFlareDependencies + + /// The configuration provider for FlareUI. + private let configurationProvider: IConfigurationProvider + + /// The singleton instance. + private static let flareUI: FlareUI = .init() + + /// Returns a shared `Flare` object. + public static var shared: IFlareUI { flareUI } + + // MARK: Initialization + + /// Initializes a new instance of FlareUI with the provided dependencies. + /// + /// - Parameter dependencies: The dependencies for FlareUI. Default is FlareDependencies(). + init(dependencies: IFlareDependencies = FlareDependencies()) { + self.dependencies = dependencies + self.configurationProvider = dependencies.configurationProvider + } + + // MARK: Public + + /// Configures the FlareUI package with the provided configuration. + /// + /// - Parameters: + /// - configuration: The configuration object containing settings for FlareUI. + public static func configure(with configuration: UIConfiguration) { + flareUI.configurationProvider.configure(with: configuration) + } +} diff --git a/Sources/FlareUI/Classes/Generated/Colors.swift b/Sources/FlareUI/Classes/Generated/Colors.swift new file mode 100644 index 000000000..f96d24c60 --- /dev/null +++ b/Sources/FlareUI/Classes/Generated/Colors.swift @@ -0,0 +1,109 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +#if os(macOS) + import AppKit +#elseif os(iOS) + import UIKit +#elseif os(tvOS) || os(watchOS) + import UIKit +#endif +#if canImport(SwiftUI) + import SwiftUI +#endif + +// Deprecated typealiases +@available(*, deprecated, renamed: "ColorAsset.Color", message: "This typealias will be removed in SwiftGen 7.0") +internal typealias AssetColorTypeAlias = ColorAsset.Color + +// swiftlint:disable superfluous_disable_command file_length implicit_return + +// MARK: - Asset Catalogs + +// swiftlint:disable identifier_name line_length nesting type_body_length type_name +internal enum Asset { + internal enum Colors { + internal static let dynamicBackground = ColorAsset(name: "Colors/dynamic_background") + internal static let gray = ColorAsset(name: "Colors/gray") + internal static let systemBackground = ColorAsset(name: "Colors/system_background") + } +} +// swiftlint:enable identifier_name line_length nesting type_body_length type_name + +// MARK: - Implementation Details + +internal final class ColorAsset { + internal fileprivate(set) var name: String + + #if os(macOS) + internal typealias Color = NSColor + #elseif os(iOS) || os(tvOS) || os(watchOS) + internal typealias Color = UIColor + #endif + + @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) + internal private(set) lazy var color: Color = { + guard let color = Color(asset: self) else { + fatalError("Unable to load color asset named \(name).") + } + return color + }() + + #if os(iOS) || os(tvOS) + @available(iOS 11.0, tvOS 11.0, *) + internal func color(compatibleWith traitCollection: UITraitCollection) -> Color { + let bundle = BundleToken.bundle + guard let color = Color(named: name, in: bundle, compatibleWith: traitCollection) else { + fatalError("Unable to load color asset named \(name).") + } + return color + } + #endif + + #if canImport(SwiftUI) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) + internal private(set) lazy var swiftUIColor: SwiftUI.Color = { + SwiftUI.Color(asset: self) + }() + #endif + + fileprivate init(name: String) { + self.name = name + } +} + +internal extension ColorAsset.Color { + @available(iOS 11.0, tvOS 11.0, watchOS 4.0, macOS 10.13, *) + convenience init?(asset: ColorAsset) { + let bundle = BundleToken.bundle + #if os(iOS) || os(tvOS) + self.init(named: asset.name, in: bundle, compatibleWith: nil) + #elseif os(macOS) + self.init(named: NSColor.Name(asset.name), bundle: bundle) + #elseif os(watchOS) + self.init(named: asset.name) + #endif + } +} + +#if canImport(SwiftUI) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +internal extension SwiftUI.Color { + init(asset: ColorAsset) { + let bundle = BundleToken.bundle + self.init(asset.name, bundle: bundle) + } +} +#endif + +// swiftlint:disable convenience_type +private final class BundleToken { + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() +} +// swiftlint:enable convenience_type diff --git a/Sources/FlareUI/Classes/Generated/Media.swift b/Sources/FlareUI/Classes/Generated/Media.swift new file mode 100644 index 000000000..846403da6 --- /dev/null +++ b/Sources/FlareUI/Classes/Generated/Media.swift @@ -0,0 +1,126 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +#if os(macOS) + import AppKit +#elseif os(iOS) + import UIKit +#elseif os(tvOS) || os(watchOS) + import UIKit +#endif +#if canImport(SwiftUI) + import SwiftUI +#endif + +// Deprecated typealiases +@available(*, deprecated, renamed: "ImageAsset.Image", message: "This typealias will be removed in SwiftGen 7.0") +internal typealias AssetImageTypeAlias = ImageAsset.Image + +// swiftlint:disable superfluous_disable_command file_length implicit_return + +// MARK: - Asset Catalogs + +// swiftlint:disable identifier_name line_length nesting type_body_length type_name +internal enum Media { + internal enum Media { + internal static let checkmark = ImageAsset(name: "Media/checkmark") + internal static let circle = ImageAsset(name: "Media/circle") + internal static let star = ImageAsset(name: "Media/star") + } +} +// swiftlint:enable identifier_name line_length nesting type_body_length type_name + +// MARK: - Implementation Details + +internal struct ImageAsset { + internal fileprivate(set) var name: String + + #if os(macOS) + internal typealias Image = NSImage + #elseif os(iOS) || os(tvOS) || os(watchOS) + internal typealias Image = UIImage + #endif + + @available(iOS 8.0, tvOS 9.0, watchOS 2.0, macOS 10.7, *) + internal var image: Image { + let bundle = BundleToken.bundle + #if os(iOS) || os(tvOS) + let image = Image(named: name, in: bundle, compatibleWith: nil) + #elseif os(macOS) + let name = NSImage.Name(self.name) + let image = (bundle == .main) ? NSImage(named: name) : bundle.image(forResource: name) + #elseif os(watchOS) + let image = Image(named: name) + #endif + guard let result = image else { + fatalError("Unable to load image asset named \(name).") + } + return result + } + + #if os(iOS) || os(tvOS) + @available(iOS 8.0, tvOS 9.0, *) + internal func image(compatibleWith traitCollection: UITraitCollection) -> Image { + let bundle = BundleToken.bundle + guard let result = Image(named: name, in: bundle, compatibleWith: traitCollection) else { + fatalError("Unable to load image asset named \(name).") + } + return result + } + #endif + + #if canImport(SwiftUI) + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) + internal var swiftUIImage: SwiftUI.Image { + SwiftUI.Image(asset: self) + } + #endif +} + +internal extension ImageAsset.Image { + @available(iOS 8.0, tvOS 9.0, watchOS 2.0, *) + @available(macOS, deprecated, + message: "This initializer is unsafe on macOS, please use the ImageAsset.image property") + convenience init?(asset: ImageAsset) { + #if os(iOS) || os(tvOS) + let bundle = BundleToken.bundle + self.init(named: asset.name, in: bundle, compatibleWith: nil) + #elseif os(macOS) + self.init(named: NSImage.Name(asset.name)) + #elseif os(watchOS) + self.init(named: asset.name) + #endif + } +} + +#if canImport(SwiftUI) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) +internal extension SwiftUI.Image { + init(asset: ImageAsset) { + let bundle = BundleToken.bundle + self.init(asset.name, bundle: bundle) + } + + init(asset: ImageAsset, label: Text) { + let bundle = BundleToken.bundle + self.init(asset.name, bundle: bundle, label: label) + } + + init(decorative asset: ImageAsset) { + let bundle = BundleToken.bundle + self.init(decorative: asset.name, bundle: bundle) + } +} +#endif + +// swiftlint:disable convenience_type +private final class BundleToken { + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() +} +// swiftlint:enable convenience_type diff --git a/Sources/FlareUI/Classes/Generated/Strings.swift b/Sources/FlareUI/Classes/Generated/Strings.swift new file mode 100644 index 000000000..b1cf247b1 --- /dev/null +++ b/Sources/FlareUI/Classes/Generated/Strings.swift @@ -0,0 +1,127 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +import Foundation + +// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references + +// MARK: - Strings + +// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces +internal enum L10n { + internal enum Common { + /// Privacy Policy + internal static let privacyPolicy = L10n.tr("Localizable", "common.privacy_policy", fallback: "Privacy Policy") + /// Terms of Service + internal static let termsOfService = L10n.tr("Localizable", "common.terms_of_service", fallback: "Terms of Service") + internal enum Subscription { + internal enum Action { + /// Subscribe + internal static let subscribe = L10n.tr("Localizable", "common.subscription.action.subscribe", fallback: "Subscribe") + } + internal enum Status { + /// Your current plan + internal static let yourCurrentPlan = L10n.tr("Localizable", "common.subscription.status.your_current_plan", fallback: "Your current plan") + /// Your plan + internal static let yourPlan = L10n.tr("Localizable", "common.subscription.status.your_plan", fallback: "Your plan") + } + } + internal enum Words { + /// and + internal static let and = L10n.tr("Localizable", "common.words.and", fallback: "and") + } + } + internal enum Error { + internal enum Default { + /// Error Occurred + internal static let title = L10n.tr("Localizable", "error.default.title", fallback: "Error Occurred") + } + } + internal enum Policies { + internal enum Unavailable { + internal enum PrivacyPolicy { + /// Something went wrong. Try again. + internal static let message = L10n.tr("Localizable", "policies.unavailable.privacy_policy.message", fallback: "Something went wrong. Try again.") + /// Privacy Policy Unavailable + internal static let title = L10n.tr("Localizable", "policies.unavailable.privacy_policy.title", fallback: "Privacy Policy Unavailable") + } + internal enum TermsOfService { + /// Something went wrong. Try again. + internal static let message = L10n.tr("Localizable", "policies.unavailable.terms_of_service.message", fallback: "Something went wrong. Try again.") + /// Terms of Service Unavailable + internal static let title = L10n.tr("Localizable", "policies.unavailable.terms_of_service.title", fallback: "Terms of Service Unavailable") + } + } + } + internal enum Product { + /// Every %@ + internal static func priceDescription(_ p1: Any) -> String { + return L10n.tr("Localizable", "product.price_description", String(describing: p1), fallback: "Every %@") + } + internal enum Subscription { + /// %@/%@ + internal static func price(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "product.subscription.price", String(describing: p1), String(describing: p2), fallback: "%@/%@") + } + } + } + internal enum StoreButton { + /// Restore Missing Purchases + internal static let restorePurchases = L10n.tr("Localizable", "store_button.restore_purchases", fallback: "Restore Missing Purchases") + } + internal enum StoreUnavailable { + /// Store Unavailable + internal static let title = L10n.tr("Localizable", "store_unavailable.title", fallback: "Store Unavailable") + internal enum Product { + /// No in-app purchases are available in the current storefront. + internal static let message = L10n.tr("Localizable", "store_unavailable.product.message", fallback: "No in-app purchases are available in the current storefront.") + } + internal enum Subscription { + /// The subscription is unavailable in the current storefront. + internal static let message = L10n.tr("Localizable", "store_unavailable.subscription.message", fallback: "The subscription is unavailable in the current storefront.") + } + } + internal enum Subscription { + internal enum Loading { + /// Loading Subscriptions... + internal static let message = L10n.tr("Localizable", "subscription.loading.message", fallback: "Loading Subscriptions...") + } + } + internal enum Subscriptions { + internal enum Renewable { + /// Plan auto-renews for %@ until cancelled. + internal static func subscriptionDescription(_ p1: Any) -> String { + return L10n.tr("Localizable", "subscriptions.renewable.subscription_description", String(describing: p1), fallback: "Plan auto-renews for %@ until cancelled.") + } + /// Plan auto-renews for %@ + /// until cancelled. + internal static func subscriptionDescriptionSeparated(_ p1: Any) -> String { + return L10n.tr("Localizable", "subscriptions.renewable.subscription_description_separated", String(describing: p1), fallback: "Plan auto-renews for %@\nuntil cancelled.") + } + } + } +} +// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces + +// MARK: - Implementation Details + +extension L10n { + private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { + let format = BundleToken.bundle.localizedString(forKey: key, value: value, table: table) + return String(format: format, locale: Locale.current, arguments: args) + } +} + +// swiftlint:disable convenience_type +private final class BundleToken { + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() +} +// swiftlint:enable convenience_type diff --git a/Sources/FlareUI/Classes/IFlareUI.swift b/Sources/FlareUI/Classes/IFlareUI.swift new file mode 100644 index 000000000..81ff40676 --- /dev/null +++ b/Sources/FlareUI/Classes/IFlareUI.swift @@ -0,0 +1,9 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// `IFlareUI` provides a way to configure the UI module. +public protocol IFlareUI {} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Controllers/BaseHostingController/BaseHostingController.swift b/Sources/FlareUI/Classes/Presentation/Components/Controllers/BaseHostingController/BaseHostingController.swift new file mode 100644 index 000000000..e2e12e3a5 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/BaseHostingController/BaseHostingController.swift @@ -0,0 +1,39 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +#if os(iOS) || os(macOS) + import SwiftUI + + @available(watchOS, unavailable) + class BaseHostingController: HostingController { + // MARK: Initialization + + override init?(coder aDecoder: NSCoder, rootView: View) { + super.init(coder: aDecoder, rootView: rootView) + setupUI() + } + + override init(rootView: View) { + super.init(rootView: rootView) + setupUI() + } + + @MainActor dynamic required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setupUI() + } + + // MARK: Private + + private func setupUI() { + #if os(iOS) || os(tvOS) + self.view.backgroundColor = .clear + #elseif os(macOS) + self.view.wantsLayer = true + self.view.layer?.backgroundColor = .clear + #endif + } + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Controllers/Helpers/ColorRepresentation.swift b/Sources/FlareUI/Classes/Presentation/Components/Controllers/Helpers/ColorRepresentation.swift new file mode 100644 index 000000000..3c272328c --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/Helpers/ColorRepresentation.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +// swiftlint:disable file_types_order + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +#if os(iOS) || os(tvOS) + public typealias ColorRepresentation = UIKit.UIColor +#elseif os(macOS) + public typealias ColorRepresentation = NSColor +#endif +// swiftlint:enable file_types_order diff --git a/Sources/FlareUI/Classes/Presentation/Components/Controllers/Helpers/SUIViewWrapper.swift b/Sources/FlareUI/Classes/Presentation/Components/Controllers/Helpers/SUIViewWrapper.swift new file mode 100644 index 000000000..47429cd37 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/Helpers/SUIViewWrapper.swift @@ -0,0 +1,66 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +#if os(iOS) || os(tvOS) + public typealias ViewRepresentation = UIKit.UIView +#elseif os(macOS) + public typealias ViewRepresentation = NSView +#elseif os(watchOS) + public typealias ViewRepresentation = WKInterfaceObject +#endif + +// MARK: - SUIViewWrapper + +struct SUIViewWrapper: ViewRepresentable { + // MARK: Types + + #if os(iOS) || os(tvOS) + typealias UIViewType = CustomView + #elseif os(macOS) + typealias NSViewType = CustomView + #elseif os(watchOS) + typealias WKInterfaceObjectType = CustomView + #endif + + // MARK: Properties + + private let view: CustomView + + // MARK: Initialization + + init(view: CustomView) { + self.view = view + } + + // MARK: ViewRepresentable + + #if os(macOS) + func makeNSView(context _: Context) -> CustomView { + view + } + + func updateNSView(_: NSViewType, context _: Context) {} + #endif + + #if os(iOS) || os(tvOS) + func makeUIView(context _: Context) -> CustomView { + view + } + + func updateUIView(_: UIViewType, context _: Context) {} + #endif + + #if os(watchOS) + func makeWKInterfaceObject(context _: Context) -> CustomView { + view + } + + func makeCoordinator() {} + + func updateWKInterfaceObject(_: CustomView, context _: Context) {} + #endif +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductViewController/ProductViewController.swift b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductViewController/ProductViewController.swift new file mode 100644 index 000000000..b9f0794f3 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductViewController/ProductViewController.swift @@ -0,0 +1,106 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import StoreKit +import SwiftUI + +// MARK: - ProductViewController + +#if os(iOS) || os(macOS) + /// A view controller for displaying a product. + /// + /// A `ProductViewController` shows information about an in-app purchase product, including its localized name, description, + /// and price, and displays a purchase button. + /// + /// You create a product view controller by providing a product identifier to load from the App Store. If you provide a product + /// identifier, + /// the view controller loads the product’s information from the App Store automatically, and updates the view when the product is + /// available. + /// + /// You can customize the product view’s appearance using the standard styles, including the ``LargeProductStyle`` and + /// ``CompactProductStyle`` styles. Apply the style using the ``ProductViewController/productStyle``. + /// + /// You can also create your own custom styles by creating styles that conform to the ``IProductStyle`` protocol. + @available(iOS 13.0, macOS 11.0, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public final class ProductViewController: ViewController { + // MARK: - Properties + + private lazy var viewModel = ProductViewControllerViewModel() + + private lazy var productView: HostingController = { + let view = ProductView(id: self.id) + .onInAppPurchaseCompletion(completion: viewModel.onInAppPurchaseCompletion) + .inAppPurchaseOptions(viewModel.inAppPurchaseOptions) + .productViewStyle(viewModel.productStyle) + + return BaseHostingController(rootView: view) + }() + + private let id: String + + /// A completion handler for in-app purchase events. + public var onInAppPurchaseCompletion: PurchaseCompletionHandler? { + didSet { + viewModel.onInAppPurchaseCompletion = onInAppPurchaseCompletion + } + } + + /// The product style. + public var productStyle: any IProductStyle = CompactProductStyle() { + didSet { + viewModel.productStyle = AnyProductStyle(style: productStyle) + } + } + + // MARK: Initialization + + /// Initialize a `ProductViewController` for the given id. + /// + /// - Parameter id: The product id. + public init(id: String) { + self.id = id + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + public required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Life Cycle + + override public func viewDidLoad() { + super.viewDidLoad() + setupUI() + } + + // MARK: Private + + private func setupUI() { + #if os(iOS) || os(tvOS) + self.view.backgroundColor = Asset.Colors.systemBackground.color + #endif + self.add(productView) + } + } + + // MARK: - Environments + + @available(iOS 13.0, macOS 11.0, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public extension ProductViewController { + /// Configures the in-app purchase options. + /// + /// - Parameter options: A closure that returns the purchase options for a given store product. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func inAppPurchaseOptions(_ options: ((StoreProduct) -> Set?)?) { + viewModel.inAppPurchaseOptions = { PurchaseOptions(options: options?($0) ?? []) } + } + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductViewController/ProductViewControllerViewModel.swift b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductViewController/ProductViewControllerViewModel.swift new file mode 100644 index 000000000..d6220ae17 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductViewController/ProductViewControllerViewModel.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import SwiftUI + +@available(watchOS, unavailable) +final class ProductViewControllerViewModel: ObservableObject { + @Published var onInAppPurchaseCompletion: PurchaseCompletionHandler? + @Published var inAppPurchaseOptions: PurchaseOptionHandler? + @Published var productStyle = AnyProductStyle(style: CompactProductStyle()) +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductsViewController/ProductsViewController.swift b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductsViewController/ProductsViewController.swift new file mode 100644 index 000000000..bbd192e58 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductsViewController/ProductsViewController.swift @@ -0,0 +1,133 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import StoreKit +import SwiftUI +#if canImport(UIKit) + import UIKit +#elseif canImport(Cocoa) + import Cocoa +#endif + +// MARK: - ProductsViewController + +#if os(iOS) || os(macOS) + /// A view for displaying multiple products. + /// + /// A `ProductsViewController` display a collection of in-app purchase products, iincluding their localized names, + /// descriptions, prices, and displays a purchase button. + /// + /// ## Customize the products view controller ## + /// + /// You can customize the controller by displaying additional buttons, and applying styles. + /// + /// You can change the product style using ``ProductsViewController/productStyle``. + @available(iOS 13.0, macOS 11.0, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public final class ProductsViewController: ViewController { + // MARK: - Properties + + private lazy var viewModel = ProductsViewControllerViewModel() + + private lazy var productsView: HostingController = { + let view = ProductsView(ids: self.ids) + .onInAppPurchaseCompletion(completion: viewModel.onInAppPurchaseCompletion) + .storeButton(.visible, types: viewModel.visibleStoreButtons) + .storeButton(.hidden, types: viewModel.hiddenStoreButtons) + .inAppPurchaseOptions(viewModel.inAppPurchaseOptions) + .productViewStyle(viewModel.productStyle) + + return BaseHostingController(rootView: view) + }() + + private let ids: any Collection + + /// A completion handler for in-app purchase events. + public var onInAppPurchaseCompletion: PurchaseCompletionHandler? { + didSet { + viewModel.onInAppPurchaseCompletion = onInAppPurchaseCompletion + } + } + + /// The product style. + public var productStyle: any IProductStyle = CompactProductStyle() { + didSet { + viewModel.productStyle = AnyProductStyle(style: productStyle) + } + } + + // MARK: Initialization + + /// Initialize a `ProductsViewController` for the given IDs. + /// + /// - Parameter ids: The products IDs. + public init(ids: some Collection) { + self.ids = ids + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + public required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Life Cycle + + override public func viewDidLoad() { + super.viewDidLoad() + setupUI() + } + + // MARK: Private + + private func setupUI() { + #if os(iOS) || os(tvOS) + self.view.backgroundColor = Asset.Colors.systemBackground.color + #endif + self.add(productsView) + } + + private func updateStoreButtons( + _ buttons: inout [StoreButtonType], + add newButtons: [StoreButtonType] + ) { + buttons += newButtons + buttons = buttons.removingDuplicates() + } + } + + // MARK: - Environments + + @available(iOS 13.0, macOS 11.0, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public extension ProductsViewController { + /// Configures the visibility of store buttons. + /// + /// - Parameters: + /// - visibility: The visibility of the store buttons. + /// - types: The types of store buttons to configure. + func storeButton(_ visibility: StoreButtonVisibility, types: [StoreButtonType]) { + switch visibility { + case .visible: + updateStoreButtons(&viewModel.visibleStoreButtons, add: types) + viewModel.hiddenStoreButtons.removeAll { types.contains($0) } + case .hidden: + updateStoreButtons(&viewModel.hiddenStoreButtons, add: types) + viewModel.visibleStoreButtons.removeAll { types.contains($0) } + } + } + + /// Configures the in-app purchase options. + /// + /// - Parameter options: A closure that returns the purchase options for a given store product. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, *) + func inAppPurchaseOptions(_ options: ((StoreProduct) -> Set?)?) { + viewModel.inAppPurchaseOptions = { PurchaseOptions(options: options?($0) ?? []) } + } + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductsViewController/ProductsViewControllerViewModel.swift b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductsViewController/ProductsViewControllerViewModel.swift new file mode 100644 index 000000000..7e028bbf0 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductsViewController/ProductsViewControllerViewModel.swift @@ -0,0 +1,16 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import SwiftUI + +@available(watchOS, unavailable) +final class ProductsViewControllerViewModel: ObservableObject { + @Published var onInAppPurchaseCompletion: PurchaseCompletionHandler? + @Published var visibleStoreButtons: [StoreButtonType] = [] + @Published var hiddenStoreButtons: [StoreButtonType] = [] + @Published var inAppPurchaseOptions: PurchaseOptionHandler? + @Published var productStyle = AnyProductStyle(style: CompactProductStyle()) +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Controllers/SubscriptionsViewController/SubscriptionsViewController.swift b/Sources/FlareUI/Classes/Presentation/Components/Controllers/SubscriptionsViewController/SubscriptionsViewController.swift new file mode 100644 index 000000000..da99bd29e --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/SubscriptionsViewController/SubscriptionsViewController.swift @@ -0,0 +1,263 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import StoreKit +import SwiftUI + +// MARK: - SubscriptionsViewController + +#if os(iOS) || os(macOS) + /// A view for displaying subscriptions. + /// + /// A `SubscriptionsViewController` displays localized information about auto-renewable subscriptions, + /// including their localized names, descriptions, prices, and a purchase button. + /// + /// ## Provide a background and a decorative icon ## + /// + /// The subscription view controller draws a default background by default. You can add a background as follows: + /// - To set the container background of the subscriptions view using a color, use the + /// ``SubscriptionsViewController/subscriptionBackgroundColor`` + /// - To set the header backgroubd of the subscriptions view controller using a color, use the + /// ``SubscriptionsViewController/subscriptionHeaderContentBackground`` + /// - To set a marketing header content, use the ``SubscriptionsViewController/subscriptionMarketingContnentView`` + /// + /// ## Provide a custom buttons appearance ## + /// + /// The `SubscriptionsViewController` can display subscription buttons in various forms. You can change the buttons appearance using + /// ``SubscriptionsViewController/subscriptionButtonLabelStyle`` or you can change the form of buttons using + /// ``SubscriptionsViewController/subscriptionControlStyle``. + /// + /// ## Handle purchase events ## + /// + /// The subscriptions view controller provides an easy way to handle purchase events as follows: + /// - To handle the completion of a purchase event, use the ``SubscriptionsViewController/onInAppPurchaseCompletion`` + /// - To pass an additional parameters to a purchase event, use the ``SubscriptionsViewController/inAppPurchaseOptions(_:)`` + /// + /// ## Customizing Behavior ## + /// + /// The `SubscriptionsViewController` draws a pin if the subscription is active. You can customize this behavior by passing a custom + /// ``ISubscriptionStatusVerifier`` inside ``UIConfiguration`` to ``FlareUI``. + /// + /// ## Add terms of service and privacy policy ## + /// + /// The `SubscriptionsViewController` can display buttons for terms of service and privacy policy. You can provide either URLs or + /// custom views with + /// this information. You can do this using modifiers + /// - ``SubscriptionsViewController/subscriptionTermsOfServiceURL`` + /// - ``SubscriptionsViewController/subscriptionTermsOfServiceView`` + /// - ``SubscriptionsViewController/subscriptionPrivacyPolicyURL`` + /// - ``SubscriptionsViewController/subscriptionPrivacyPolicyView`` + /// + /// ## Add auxiliary buttons ## + /// + /// The `SubscriptionsViewController` can display auxiliary buttons like Restore Purchases. You can specify button visibility within the + /// subscription + /// view using ``SubscriptionsViewController/storeButton(_:types:)``. + @available(iOS 13.0, macOS 11.0, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public final class SubscriptionsViewController: ViewController { + // MARK: Properties + + private lazy var viewModel = SubscriptionsViewControllerViewModel() + + private lazy var subscriptionsView: HostingController = { + let view = SubscriptionsView(ids: self.ids) + .onInAppPurchaseCompletion(completion: viewModel.onInAppPurchaseCompletion) + .inAppPurchaseOptions(viewModel.inAppPurchaseOptions) + .subscriptionControlStyle(viewModel.subscriptionControlStyle) + .subscriptionBackground(viewModel.subscriptionBackgroundColor) + .subscriptionViewTint(viewModel.subscriptionViewTintColor) + .subscriptionButtonLabel(viewModel.subscriptionButtonLabelStyle) + .storeButton(.visible, types: viewModel.visibleStoreButtons) + .storeButton(.hidden, types: viewModel.hiddenStoreButtons) + .subscriptionMarketingContent { viewModel.marketingContent } + #if os(iOS) || os(tvOS) + .subscriptionHeaderContentBackground(viewModel.subscriptionHeaderContentBackground) + #endif + #if os(iOS) + .subscriptionPrivacyPolicyURL(viewModel.subscriptionPrivacyPolicyURL) + .subscriptionTermsOfServiceURL(viewModel.subscriptionTermsOfServiceURL) + #endif + + return BaseHostingController(rootView: view) + }() + + private let ids: any Collection + + /// A completion handler for in-app purchase events. + public var onInAppPurchaseCompletion: PurchaseCompletionHandler? { + didSet { + viewModel.onInAppPurchaseCompletion = onInAppPurchaseCompletion + } + } + + /// The style of the subscription control. + public var subscriptionControlStyle: any ISubscriptionControlStyle = AutomaticSubscriptionControlStyle() { + didSet { + viewModel.subscriptionControlStyle = AnySubscriptionControlStyle(style: subscriptionControlStyle) + } + } + + /// The background color of the subscription view. + public var subscriptionBackgroundColor: ColorRepresentation = .clear { + didSet { + viewModel.subscriptionBackgroundColor = Color(subscriptionBackgroundColor) + } + } + + /// The tint color of the subscription view. + public var subscriptionViewTintColor: ColorRepresentation = .blue { + didSet { + viewModel.subscriptionViewTintColor = Color(subscriptionViewTintColor) + } + } + + /// The style of the subscription button label. + public var subscriptionButtonLabelStyle: SubscriptionStoreButtonLabel = .action { + didSet { + viewModel.subscriptionButtonLabelStyle = subscriptionButtonLabelStyle + } + } + + /// The view for marketing content. + public var subscriptionMarketingContnentView: ViewRepresentation? { + didSet { + guard let subscriptionMarketingContnentView else { + viewModel.marketingContent = nil + return + } + viewModel.marketingContent = SUIViewWrapper( + view: subscriptionMarketingContnentView + ) + .eraseToAnyView() + } + } + + #if os(iOS) || os(tvOS) + /// The background color of the subscription header content. + public var subscriptionHeaderContentBackground: ColorRepresentation = .clear { + didSet { + viewModel.subscriptionHeaderContentBackground = Color(subscriptionHeaderContentBackground) + } + } + #endif + + #if os(iOS) + /// The URL for the privacy policy. + public var subscriptionPrivacyPolicyURL: URL? { + didSet { + viewModel.subscriptionPrivacyPolicyURL = subscriptionPrivacyPolicyURL + } + } + + /// The URL for the terms of service. + public var subscriptionTermsOfServiceURL: URL? { + didSet { + viewModel.subscriptionTermsOfServiceURL = subscriptionTermsOfServiceURL + } + } + #endif + + /// The view for the privacy policy. + public var subscriptionPrivacyPolicyView: ViewRepresentation? { + didSet { + guard let subscriptionPrivacyPolicyView else { + self.viewModel.subscriptionPrivacyPolicyView = nil + return + } + viewModel.subscriptionPrivacyPolicyView = SUIViewWrapper( + view: subscriptionPrivacyPolicyView + ).eraseToAnyView() + } + } + + /// The view for the terms of service. + public var subscriptionTermsOfServiceView: ViewRepresentation? { + didSet { + guard let subscriptionTermsOfServiceView else { + self.viewModel.subscriptionTermsOfServiceView = nil + return + } + viewModel.subscriptionTermsOfServiceView = SUIViewWrapper( + view: subscriptionTermsOfServiceView + ).eraseToAnyView() + } + } + + // MARK: Initialization + + /// Initialize a `SubscriptionsViewController` for the given IDs. + /// + /// - Parameter ids: The subscriptions IDs. + public init(ids: any Collection) { + self.ids = ids + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + public required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Life Cycle + + override public func viewDidLoad() { + super.viewDidLoad() + setupUI() + } + + // MARK: Private + + private func setupUI() { + #if os(iOS) || os(tvOS) + self.view.backgroundColor = Asset.Colors.systemBackground.color + #endif + self.add(subscriptionsView) + } + + private func updateStoreButtons( + _ buttons: inout [StoreButtonType], + add newButtons: [StoreButtonType] + ) { + buttons += newButtons + buttons = buttons.removingDuplicates() + } + } + + // MARK: - Environments + + /// Extension for configuring store button visibility. + @available(iOS 13.0, macOS 11.0, tvOS 13.0, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public extension SubscriptionsViewController { + /// Configures the visibility of store buttons. + /// + /// - Parameters: + /// - visibility: The visibility of the store buttons. + /// - types: The types of store buttons to configure. + func storeButton(_ visibility: StoreButtonVisibility, types: [StoreButtonType]) { + switch visibility { + case .visible: + updateStoreButtons(&viewModel.visibleStoreButtons, add: types) + viewModel.hiddenStoreButtons.removeAll { types.contains($0) } + case .hidden: + updateStoreButtons(&viewModel.hiddenStoreButtons, add: types) + viewModel.visibleStoreButtons.removeAll { types.contains($0) } + } + } + + /// Configures the in-app purchase options. + /// + /// - Parameter options: A closure that returns the purchase options for a given store product. + @available(iOS 15.0, tvOS 15.0, macOS 12.0, *) + func inAppPurchaseOptions(_ options: ((StoreProduct) -> Set?)?) { + viewModel.inAppPurchaseOptions = { PurchaseOptions(options: options?($0) ?? []) } + } + } + +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Controllers/SubscriptionsViewController/SubscriptionsViewControllerViewModel.swift b/Sources/FlareUI/Classes/Presentation/Components/Controllers/SubscriptionsViewController/SubscriptionsViewControllerViewModel.swift new file mode 100644 index 000000000..bad85892b --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/SubscriptionsViewController/SubscriptionsViewControllerViewModel.swift @@ -0,0 +1,31 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation +import SwiftUI + +@available(iOS 13.0, macOS 11.0, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +final class SubscriptionsViewControllerViewModel: ObservableObject { + @Published var onInAppPurchaseCompletion: PurchaseCompletionHandler? + @Published var inAppPurchaseOptions: PurchaseOptionHandler? + @Published var marketingContent: AnyView? + @Published var subscriptionButtonLabelStyle: SubscriptionStoreButtonLabel = .action + @Published var subscriptionBackgroundColor: Color = .clear + @Published var subscriptionViewTintColor: Color = .blue + @Published var subscriptionControlStyle: AnySubscriptionControlStyle = .init(style: AutomaticSubscriptionControlStyle()) + @Published var visibleStoreButtons: [StoreButtonType] = [] + @Published var hiddenStoreButtons: [StoreButtonType] = [] + #if os(iOS) || os(tvOS) + @Published var subscriptionHeaderContentBackground: Color = .clear + #endif + #if os(iOS) + @Published var subscriptionPrivacyPolicyURL: URL? + @Published var subscriptionTermsOfServiceURL: URL? + #endif + @Published var subscriptionPrivacyPolicyView: AnyView? + @Published var subscriptionTermsOfServiceView: AnyView? +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Controllers/ViewController/HostingController.swift b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ViewController/HostingController.swift new file mode 100644 index 000000000..d49194d5e --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ViewController/HostingController.swift @@ -0,0 +1,20 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#elseif canImport(Cocoa) + import Cocoa +#endif + +import SwiftUI + +#if os(iOS) || os(tvOS) + typealias HostingController = UIHostingController +#elseif os(macOS) + typealias HostingController = NSHostingController +#elseif os(watchOS) + typealias HostingController = WKHostingController +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Controllers/ViewController/ViewController.swift b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ViewController/ViewController.swift new file mode 100644 index 000000000..25b583ed0 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ViewController/ViewController.swift @@ -0,0 +1,20 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#elseif canImport(Cocoa) + import Cocoa +#endif + +import SwiftUI + +#if os(iOS) || os(tvOS) + public typealias ViewController = UIViewController +#elseif os(macOS) + public typealias ViewController = NSViewController +#elseif os(watchOS) + public typealias ViewController = WKInterfaceController +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Core/Constants/Palette.swift b/Sources/FlareUI/Classes/Presentation/Components/Core/Constants/Palette.swift new file mode 100644 index 000000000..9766e7c05 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Core/Constants/Palette.swift @@ -0,0 +1,62 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +#if os(iOS) + import UIKit +#elseif os(macOS) + import Cocoa +#endif + +import SwiftUI + +// MARK: - Palette + +enum Palette { + static var gray: Color { + Asset.Colors.gray.swiftUIColor + } + + static var dynamicBackground: Color { + Asset.Colors.dynamicBackground.swiftUIColor + } + + static var systemBackground: Color { + Asset.Colors.systemBackground.swiftUIColor + } + + static var systemGray5: Color { + #if os(iOS) + Color(UIColor.systemGray5) + #else + systemGray + #endif + } + + static var systemGray2: Color { + #if os(iOS) + Color(UIColor.systemGray2) + #else + systemGray + #endif + } + + static var systemGray4: Color { + #if os(iOS) + Color(UIColor.systemGray4) + #else + systemGray + #endif + } + + static var systemGray: Color { + #if os(macOS) + Color(NSColor.systemGray) + #elseif os(watchOS) + Color(UIColor.gray) + #else + Color(UIColor.systemGray) + #endif + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Core/Constants/UIConstants.swift b/Sources/FlareUI/Classes/Presentation/Components/Core/Constants/UIConstants.swift new file mode 100644 index 000000000..4795a5513 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Core/Constants/UIConstants.swift @@ -0,0 +1,16 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +extension CGFloat { + /// 4px + static let cornerRadius4px = 4.0 +} + +extension CGFloat { + /// 10px + static let spacing10px = 10.0 +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Core/Models/StoreButtonType.swift b/Sources/FlareUI/Classes/Presentation/Components/Core/Models/StoreButtonType.swift new file mode 100644 index 000000000..29f2beefa --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Core/Models/StoreButtonType.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - StoreButtonType + +/// Enum representing different types of buttons in a store. +public enum StoreButtonType { + /// Button for restoring purchases. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + case restore + + /// Button for displaying store policies. + case policies +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Core/Models/StoreButtonVisibility.swift b/Sources/FlareUI/Classes/Presentation/Components/Core/Models/StoreButtonVisibility.swift new file mode 100644 index 000000000..ab3cfaa49 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Core/Models/StoreButtonVisibility.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - StoreButtonVisibility + +/// Enum representing the visibility states of a store button. +public enum StoreButtonVisibility { + /// The button is visible. + case visible + + /// The button is hidden. + case hidden +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Core/Protocols/IModel.swift b/Sources/FlareUI/Classes/Presentation/Components/Core/Protocols/IModel.swift new file mode 100644 index 000000000..800d8b78c --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Core/Protocols/IModel.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// A type that represents a default model object. +protocol IModel { + /// The associated type representing the state of the model. + associatedtype State + + /// The current state of the model. + var state: State { get } + + /// Function to set the state of the model and return a new instance with the updated state. + /// + /// - Parameter state: The new state to set. + /// + /// - Returns: A new instance of the model with the updated state. + func setState(_ state: State) -> Self +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Core/Protocols/IPresenter.swift b/Sources/FlareUI/Classes/Presentation/Components/Core/Protocols/IPresenter.swift new file mode 100644 index 000000000..5eb44684a --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Core/Protocols/IPresenter.swift @@ -0,0 +1,39 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - IPresenter + +/// A protocol that defines the basic functionality of a presenter. +protocol IPresenter { + /// The associated type representing the model associated with the presenter. + associatedtype Model: IModel + + /// The view model associated with the presenter. + var viewModel: WrapperViewModel? { get } + + /// Updates the state of the presenter's model. + /// + /// - Parameters: + /// - state: The new state to update to. + /// - animation: The animation to use for the state update. + func update(state: Model.State, animation: Animation?) +} + +extension IPresenter { + /// Default implementation for updating the presenter's model state. + /// + /// - Parameters: + /// - state: The new state to update to. + /// - animation: The animation to use for the state update. + func update(state: Model.State, animation: Animation? = .default) { + guard let viewModel = viewModel else { return } + + withAnimation(animation) { + viewModel.model = viewModel.model.setState(state) + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Factories/ISubscriptionPriceViewModelFactory.swift b/Sources/FlareUI/Classes/Presentation/Components/Factories/ISubscriptionPriceViewModelFactory.swift new file mode 100644 index 000000000..3d2ad3657 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Factories/ISubscriptionPriceViewModelFactory.swift @@ -0,0 +1,25 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +// MARK: - ISubscriptionPriceViewModelFactory + +/// Protocol for creating view models representing subscription prices. +protocol ISubscriptionPriceViewModelFactory { + /// Creates a string representing the price of a subscription product. + /// + /// - Parameters: + /// - product: The subscription product. + /// - format: The format in which to display the price. + /// - Returns: A string representing the price. + func make(_ product: StoreProduct, format: PriceDisplayFormat) -> String + + /// Retrieves the period of a subscription product. + /// + /// - Parameter product: The subscription product. + /// - Returns: A string representing the subscription period. + func period(from product: StoreProduct) -> String? +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Factories/SubscriptionPriceViewModelFactory.swift b/Sources/FlareUI/Classes/Presentation/Components/Factories/SubscriptionPriceViewModelFactory.swift new file mode 100644 index 000000000..eb28416a8 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Factories/SubscriptionPriceViewModelFactory.swift @@ -0,0 +1,105 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +/// A class for creating view models representing subscription prices. +final class SubscriptionPriceViewModelFactory: ISubscriptionPriceViewModelFactory { + // MARK: Properties + + /// The date formatter used to format date components. + private var dateFormatter: IDateComponentsFormatter + + /// The factory for creating subscription date components. + private let subscriptionDateComponentsFactory: ISubscriptionDateComponentsFactory + + // MARK: Initialization + + /// Initializes the factory with the specified date formatter and subscription date components factory. + /// + /// - Parameters: + /// - dateFormatter: The date formatter to use. Default is `DateComponentsFormatter.full`. + /// - subscriptionDateComponentsFactory: The factory for creating subscription date components. Default is + /// `SubscriptionDateComponentsFactory()`. + init( + dateFormatter: IDateComponentsFormatter = DateComponentsFormatter.full, + subscriptionDateComponentsFactory: ISubscriptionDateComponentsFactory = SubscriptionDateComponentsFactory() + ) { + self.dateFormatter = dateFormatter + self.subscriptionDateComponentsFactory = subscriptionDateComponentsFactory + } + + // MARK: ISubscriptionPriceViewModelFactory + + func make(_ product: StoreProduct, format: PriceDisplayFormat) -> String { + makePrice(from: product, format: format) + } + + func period(from product: StoreProduct) -> String? { + guard let period = product.subscriptionPeriod else { return nil } + + let unit = makeUnit(from: period.unit) + dateFormatter.allowedUnits = [unit] + + let dateComponents = subscriptionDateComponentsFactory.dateComponents(for: period) + let localizedPeriod = dateFormatter.string(from: dateComponents) + + return localizedPeriod + } + + // MARK: Private + + private func makePrice(from product: StoreProduct, format: PriceDisplayFormat) -> String { + switch product.productType { + case .consumable, .nonConsumable, .nonRenewableSubscription: + return product.localizedPriceString ?? "" + case .autoRenewableSubscription: + guard let period = product.subscriptionPeriod else { return "" } + + switch format { + case .short: + return product.localizedPriceString ?? "" + case .full: + let unit = makeUnit(from: period.unit) + if unit == .day, period.value == 7 { + dateFormatter.allowedUnits = [.weekOfMonth] + } else { + dateFormatter.allowedUnits = [unit] + } + + let dateComponents = subscriptionDateComponentsFactory.dateComponents(for: period) + let localizedPeriod = dateFormatter.string(from: dateComponents) + + return [product.localizedPriceString, String(localizedPeriod?.words.last)] + .compactMap { $0 } + .joined(separator: "/") + } + case .none: + return "" + } + } + + private func makeUnit(from unit: SubscriptionPeriod.Unit) -> NSCalendar.Unit { + switch unit { + case .day: + return .day + case .week: + return .weekOfMonth + case .month: + return .month + case .year: + return .year + } + } + + private func makePriceDescription(from product: StoreProduct) -> String? { + let localizedPeriod = period(from: product) + + guard let string = localizedPeriod?.words.last else { return nil } + + return L10n.Product.priceDescription(string).capitalized + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+Contrast.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+Contrast.swift new file mode 100644 index 000000000..eea94fb18 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+Contrast.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// swiftlint:disable identifier_name +extension View { + func contrast( + _ backgroundColor: Color, + lightColor: Color = .white, + darkColor: Color = .black + ) -> some View { + var r, g, b, a: CGFloat + (r, g, b, a) = (0, 0, 0, 0) + backgroundColor.uiColor().getRed(&r, green: &g, blue: &b, alpha: &a) + let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b + return luminance < 0.6 ? foregroundColor(lightColor) : foregroundColor(darkColor) + } +} + +// swiftlint:enable identifier_name diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+Paywall.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+Paywall.swift new file mode 100644 index 000000000..9160f8ad0 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+Paywall.swift @@ -0,0 +1,26 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// A SwiftUI extension to add a paywall to a view. +public extension View { + /// Adds a paywall to the view. + /// + /// - Parameters: + /// - presented: A binding to control the presentation state of the paywall. + /// - paywallType: The type of paywall to display. + /// + /// - Returns: A modified view with the paywall added. + @available(watchOS, unavailable) + func paywall(presented: Binding, paywallType: PaywallType) -> some View { + modifier( + PaywallViewModifier( + paywallType: paywallType, + presented: presented + ) + ) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+ProductViewStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+ProductViewStyle.swift new file mode 100644 index 000000000..c9e92330d --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+ProductViewStyle.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for applying a specific style to a view representing a product. +public extension View { + /// Sets the style for the product view. + /// + /// - Parameter style: The style to apply to the product view. + /// + /// - Returns: A modified view with the specified style applied. + func productViewStyle(_ style: some IProductStyle) -> some View { + environment(\.productViewStyle, AnyProductStyle(style: style)) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+PurchaseCompletion.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+PurchaseCompletion.swift new file mode 100644 index 000000000..dbe7ce9d5 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+PurchaseCompletion.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import SwiftUI + +/// Extension for handling in-app purchase completions within a view. +public extension View { + /// Sets a completion handler for in-app purchase transactions. + /// + /// - Parameter completion: The completion handler to execute when an in-app purchase transaction completes. + /// + /// - Returns: A modified view with the specified completion handler. + func onInAppPurchaseCompletion(completion: ((StoreProduct, Result) -> Void)?) -> some View { + environment(\.purchaseCompletion, completion) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+PurchaseOption.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+PurchaseOption.swift new file mode 100644 index 000000000..ab64d00b5 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+PurchaseOption.swift @@ -0,0 +1,30 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import StoreKit +import SwiftUI + +/// Extension for configuring in-app purchase options within a view. +extension View { + /// Sets the in-app purchase options for the view. + /// + /// - Parameter options: A closure that returns the set of purchase options for a given store product. + /// + /// - Returns: A modified view with the specified in-app purchase options. + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public func inAppPurchaseOptions(_ options: ((StoreProduct) -> Set)?) -> some View { + environment(\.purchaseOptions) { PurchaseOptions(options: options?($0) ?? []) } + } + + /// Sets the in-app purchase options for the view. + /// + /// - Parameter options: A closure that returns the purchase options for a given store product. + /// + /// - Returns: A modified view with the specified in-app purchase options. + func inAppPurchaseOptions(_ options: ((StoreProduct) -> PurchaseOptions)?) -> some View { + environment(\.purchaseOptions, options) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+StoreButton.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+StoreButton.swift new file mode 100644 index 000000000..f2887fa2f --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+StoreButton.swift @@ -0,0 +1,47 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the visibility and types of store buttons within a view. +public extension View { + /// Sets the visibility and types of store buttons for the view. + /// + /// - Parameters: + /// - visibility: The visibility of the store buttons (visible or hidden). + /// - types: The types of store buttons to show or hide. + /// + /// - Returns: A modified view with the specified store button configuration. + func storeButton(_ visibility: StoreButtonVisibility, types: StoreButtonType...) -> some View { + transformEnvironment(\.storeButton) { values in + if visibility == .hidden { + values = values.filter { !types.contains($0) } + } else { + let types = types.removingDuplicates() + let diff = types.filter { !values.contains($0) } + values += diff + } + } + } + + /// Sets the visibility and types of store buttons for the view. + /// + /// - Parameters: + /// - visibility: The visibility of the store buttons (visible or hidden). + /// - types: The types of store buttons to show or hide. + /// + /// - Returns: A modified view with the specified store button configuration. + func storeButton(_ visibility: StoreButtonVisibility, types: [StoreButtonType]) -> some View { + transformEnvironment(\.storeButton) { values in + if visibility == .hidden { + values = values.filter { !types.contains($0) } + } else { + let types = types.removingDuplicates() + let diff = types.filter { !values.contains($0) } + values += diff + } + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+StoreButtonViewFontWeight.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+StoreButtonViewFontWeight.swift new file mode 100644 index 000000000..ebd18abd1 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+StoreButtonViewFontWeight.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the font weight of store button views within a view. +extension View { + /// Sets the font weight of store button views for the view. + /// + /// - Parameter weight: The font weight to apply to store button views. + /// + /// - Returns: A modified view with the specified font weight for store button views. + func storeButtonViewFontWeight(_ weight: Font.Weight) -> some View { + environment(\.storeButtonViewFontWeight, weight) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionBackground.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionBackground.swift new file mode 100644 index 000000000..5ec2e1da3 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionBackground.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the background color of subscription views within a view. +public extension View { + /// Sets the background color of subscription views for the view. + /// + /// - Parameter color: The background color to apply to subscription views. + /// + /// - Returns: A modified view with the specified background color for subscription views. + @available(iOS 13.0, macOS 10.15, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + func subscriptionBackground(_ color: Color) -> some View { + environment(\.subscriptionBackground, color) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionControlStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionControlStyle.swift new file mode 100644 index 000000000..17414b32a --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionControlStyle.swift @@ -0,0 +1,34 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the style of subscription controls within a view. +public extension View { + /// Sets the style of subscription controls for the view. + /// + /// - Parameter style: The style to apply to subscription controls. + /// + /// - Returns: A modified view with the specified style for subscription controls. + @available(watchOS, unavailable) + func subscriptionControlStyle(_ style: some ISubscriptionControlStyle) -> some View { + environment(\.subscriptionControlStyle, prepareStyle(style)) + } + + // MARK: Private + + /// Prepares the style for subscription controls. + /// + /// - Parameter style: The style to prepare. + /// + /// - Returns: The prepared style as an `AnySubscriptionControlStyle`. + private func prepareStyle(_ style: some ISubscriptionControlStyle) -> AnySubscriptionControlStyle { + if let style = style as? AnySubscriptionControlStyle { + return style + } else { + return AnySubscriptionControlStyle(style: style) + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionHeaderContentBackground.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionHeaderContentBackground.swift new file mode 100644 index 000000000..c8aa64d37 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionHeaderContentBackground.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the background color of subscription header content within a view. +public extension View { + /// Sets the background color of subscription header content for the view. + /// + /// - Parameter color: The background color to apply to subscription header content. + /// + /// - Returns: A modified view with the specified background color for subscription header content. + @available(iOS 13.0, *) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + func subscriptionHeaderContentBackground(_ color: Color) -> some View { + environment(\.subscriptionHeaderContentBackground, color) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionMarketingContent.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionMarketingContent.swift new file mode 100644 index 000000000..930a795f9 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionMarketingContent.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the marketing content of a subscription within a view. +public extension View { + /// Sets the marketing content for the subscription view. + /// + /// - Parameter view: A closure that returns the marketing content as a view. + /// + /// - Returns: A modified view with the specified marketing content for the subscription. + func subscriptionMarketingContent(@ViewBuilder view: () -> some View) -> some View { + environment(\.subscriptionMarketingContent, view().eraseToAnyView()) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionPickerItemBackground.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionPickerItemBackground.swift new file mode 100644 index 000000000..dff965a82 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionPickerItemBackground.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the label style of subscription store buttons within a view. +public extension View { + /// Sets the label style for subscription store buttons for the view. + /// + /// - Parameter style: The label style to apply to subscription store buttons. + /// + /// - Returns: A modified view with the specified label style for subscription store buttons. + @available(iOS 13.0, macOS 10.15, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + func subscriptionButtonLabel(_ style: SubscriptionStoreButtonLabel) -> some View { + environment(\.subscriptionStoreButtonLabel, style) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionPrivacyPolicyDestination.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionPrivacyPolicyDestination.swift new file mode 100644 index 000000000..0cf3993bc --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionPrivacyPolicyDestination.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the destination of the privacy policy for a subscription within a view. +public extension View { + /// Sets the destination for the privacy policy of the subscription view. + /// + /// - Parameter content: A closure that returns the view representing the destination. + /// + /// - Returns: A modified view with the specified destination for the privacy policy. + func subscriptionPrivacyPolicyDestination(@ViewBuilder content: () -> some View) -> some View { + environment(\.subscriptionPrivacyPolicyDestination, content().eraseToAnyView()) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionPrivacyPolicyURL.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionPrivacyPolicyURL.swift new file mode 100644 index 000000000..88becea59 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionPrivacyPolicyURL.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the URL of the privacy policy for a subscription within a view. +public extension View { + /// Sets the URL for the privacy policy of the subscription view. + /// + /// - Parameter url: The URL of the privacy policy. + /// + /// - Returns: A modified view with the specified URL for the privacy policy. + @available(iOS 13.0, *) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + func subscriptionPrivacyPolicyURL(_ url: URL?) -> some View { + environment(\.subscriptionPrivacyPolicyURL, url) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionStoreButtonLabel.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionStoreButtonLabel.swift new file mode 100644 index 000000000..248bae914 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionStoreButtonLabel.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the background color of subscription picker items within a view. +public extension View { + /// Sets the background color of subscription picker items for the view. + /// + /// - Parameter backgroundStyle: The background color to apply to subscription picker items. + /// + /// - Returns: A modified view with the specified background color for subscription picker items. + @available(iOS 13.0, macOS 10.15, *) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + func subscriptionPickerItemBackground(_ backgroundStyle: Color) -> some View { + environment(\.subscriptionPickerItemBackground, backgroundStyle) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionTermsOfServiceDestination.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionTermsOfServiceDestination.swift new file mode 100644 index 000000000..a53e2dd36 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionTermsOfServiceDestination.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the destination of the terms of service for a subscription within a view. +public extension View { + /// Sets the destination for the terms of service of the subscription view. + /// + /// - Parameter content: A closure that returns the view representing the destination. + /// + /// - Returns: A modified view with the specified destination for the terms of service. + func subscriptionTermsOfServiceDestination(@ViewBuilder content: () -> some View) -> some View { + environment(\.subscriptionTermsOfServiceDestination, content().eraseToAnyView()) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionTermsOfServiceURL.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionTermsOfServiceURL.swift new file mode 100644 index 000000000..ee8c915fc --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionTermsOfServiceURL.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the URL of the terms of service for a subscription within a view. +public extension View { + /// Sets the URL for the terms of service of the subscription view. + /// + /// - Parameter url: The URL of the terms of service. + /// + /// - Returns: A modified view with the specified URL for the terms of service. + @available(iOS 13.0, *) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + func subscriptionTermsOfServiceURL(_ url: URL?) -> some View { + environment(\.subscriptionTermsOfServiceURL, url) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionViewTint.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionViewTint.swift new file mode 100644 index 000000000..666f5400a --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionViewTint.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the tint color of subscription views within a view. +public extension View { + /// Sets the tint color for subscription views for the view. + /// + /// - Parameter color: The tint color to apply to subscription views. + /// + /// - Returns: A modified view with the specified tint color for subscription views. + func subscriptionViewTint(_ color: Color) -> some View { + environment(\.subscriptionViewTint, color) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+TintColor.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+TintColor.swift new file mode 100644 index 000000000..486112866 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+TintColor.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// Extension for configuring the tint color of views within a view hierarchy. +public extension View { + /// Sets the tint color for views in the view hierarchy. + /// + /// - Parameter color: The tint color to apply to views. + /// + /// - Returns: A modified view with the specified tint color for views. + func tintColor(_ color: Color) -> some View { + environment(\.tintColor, color) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Helpers/UIKit/ViewController+Child.swift b/Sources/FlareUI/Classes/Presentation/Components/Helpers/UIKit/ViewController+Child.swift new file mode 100644 index 000000000..45da9b7e1 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/UIKit/ViewController+Child.swift @@ -0,0 +1,66 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +#if canImport(UIKit) + import UIKit +#elseif canImport(Cocoa) + import Cocoa +#endif + +#if os(iOS) || os(macOS) + extension ViewController { + func add(_ controller: ViewController) { + addChild(controller) + view.addSubview(controller.view) + + #if os(iOS) || os(tvOS) + controller.didMove(toParent: self) + #endif + + controller.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + controller.view.topAnchor.constraint(equalTo: self.safeTopAnchor), + controller.view.leadingAnchor.constraint(equalTo: self.safeLeadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: self.safeTrailingAnchor), + controller.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + ]) + } + + // MARK: Private + + private var safeTopAnchor: NSLayoutYAxisAnchor { + if #available(iOS 11.0, macOS 11.0, *) { + return view.safeAreaLayoutGuide.topAnchor + } else { + return view.bottomAnchor + } + } + + private var safeBottomAnchor: NSLayoutYAxisAnchor { + if #available(iOS 11.0, macOS 11.0, *) { + return view.safeAreaLayoutGuide.bottomAnchor + } else { + return view.topAnchor + } + } + + private var safeLeadingAnchor: NSLayoutXAxisAnchor { + if #available(iOS 11.0, macOS 11.0, *) { + return view.safeAreaLayoutGuide.leadingAnchor + } else { + return view.leadingAnchor + } + } + + private var safeTrailingAnchor: NSLayoutXAxisAnchor { + if #available(iOS 11.0, macOS 11.0, *) { + return view.safeAreaLayoutGuide.trailingAnchor + } else { + return view.trailingAnchor + } + } + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/BorderedButtonStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/BorderedButtonStyle.swift new file mode 100644 index 000000000..785a02250 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/BorderedButtonStyle.swift @@ -0,0 +1,37 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - BorderedButtonStyle + +struct BorderedButtonStyle: ButtonStyle { + // MARK: ButtonStyle + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, .horizontalPadding) + .padding(.vertical, .verticalPadding) + .background(Palette.gray) + .foregroundColor(.blue) + .mask(Capsule()) + .opacity(configuration.isPressed ? 0.5 : 1.0) + } +} + +// MARK: Extensions + +extension ButtonStyle where Self == BorderedButtonStyle { + static var bordered: Self { + .init() + } +} + +// MARK: Constants + +private extension CGFloat { + static let horizontalPadding = 16.0 + static let verticalPadding = 8.0 +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/PrimaryButtonStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/PrimaryButtonStyle.swift new file mode 100644 index 000000000..2001bc60e --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/PrimaryButtonStyle.swift @@ -0,0 +1,43 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - PrimaryButtonStyle + +@available(iOS 13.0, macOS 10.15, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +struct PrimaryButtonStyle: ButtonStyle { + // MARK: Properties + + @Environment(\.tintColor) private var tintColor + + // MARK: ButtonStyle + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundColor(.white) + .frame(height: 50.0) + .frame(maxWidth: .infinity) + .padding(.horizontal) + .background(tintColor) + .clipShape(RoundedRectangle(cornerSize: .init(width: 14, height: 14))) + .opacity(configuration.isPressed ? 0.5 : 1.0) + } +} + +// MARK: - Extensions + +@available(iOS 13.0, macOS 10.15, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +extension ButtonStyle where Self == PrimaryButtonStyle { + static var primary: PrimaryButtonStyle { + PrimaryButtonStyle() + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/CompactProductStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/CompactProductStyle.swift new file mode 100644 index 000000000..7f9e170c1 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/CompactProductStyle.swift @@ -0,0 +1,37 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public struct CompactProductStyle: IProductStyle { + // MARK: Properties + + private var viewModelFactory: IProductViewModelFactory + + // MARK: Initialization + + public init() { + self.init(viewModelFactory: ProductViewModelFactory()) + } + + init(viewModelFactory: IProductViewModelFactory = ProductViewModelFactory()) { + self.viewModelFactory = viewModelFactory + } + + // MARK: IProductStyle + + @ViewBuilder + public func makeBody(configuration: ProductStyleConfiguration) -> some View { + switch configuration.state { + case .loading: + ProductPlaceholderView(isIconHidden: configuration.icon == nil, style: .compact) + case let .product(product): + let viewModel = viewModelFactory.make(product, style: .compact) + ProductInfoView(viewModel: viewModel, icon: configuration.icon, style: .compact) { configuration.purchase() } + case .error: + ProductPlaceholderView(isIconHidden: configuration.icon == nil, style: .compact) + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/Configuration/ProductStyleConfiguration.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/Configuration/ProductStyleConfiguration.swift new file mode 100644 index 000000000..50664f44f --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/Configuration/ProductStyleConfiguration.swift @@ -0,0 +1,62 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import SwiftUI + +/// Configuration for the style of a product, including its state and purchase action. +public struct ProductStyleConfiguration { + // MARK: Types + + /// Represents the state of the product style. + public enum State { + /// The product is currently loading. + case loading + /// The product is available for purchase, with the specified store product item. + case product(item: StoreProduct) + /// An error occurred while loading the product. + case error(error: IAPError) + } + + /// Represents the icon view for the product. + public struct Icon: View { + // MARK: Properties + + /// The body of the icon view. + public var body: AnyView + + // MARK: Initialization + + /// Initializes the icon view with the specified content. + /// + /// - Parameter content: The content of the icon view. + public init(content: Content) { + body = AnyView(content) + } + } + + // MARK: Properties + + /// The icon view for the product. + public let icon: Icon? + /// The state of the product. + public let state: State + /// The purchase action. + public let purchase: () -> Void + + // MARK: Initialization + + /// Initializes the product style configuration with the specified parameters. + /// + /// - Parameters: + /// - icon: The icon view for the product. + /// - state: The state of the product. + /// - purchase: The purchase action. + public init(icon: Icon? = nil, state: State, purchase: @escaping () -> Void = {}) { + self.icon = icon + self.state = state + self.purchase = purchase + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/LargeProductStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/LargeProductStyle.swift new file mode 100644 index 000000000..5bffa4126 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/LargeProductStyle.swift @@ -0,0 +1,41 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(iOS 13, *) +@available(macOS, unavailable) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +public struct LargeProductStyle: IProductStyle { + // MARK: Properties + + private var viewModelFactory: IProductViewModelFactory + + // MARK: Initialization + + public init() { + self.init(viewModelFactory: ProductViewModelFactory()) + } + + init(viewModelFactory: IProductViewModelFactory = ProductViewModelFactory()) { + self.viewModelFactory = viewModelFactory + } + + // MARK: IProductStyle + + @ViewBuilder + public func makeBody(configuration: ProductStyleConfiguration) -> some View { + switch configuration.state { + case .loading: + ProductPlaceholderView(isIconHidden: configuration.icon == nil, style: .large) + case let .product(product): + let viewModel = viewModelFactory.make(product, style: .large) + ProductInfoView(viewModel: viewModel, icon: configuration.icon, style: .large) { configuration.purchase() } + case .error: + ProductPlaceholderView(isIconHidden: configuration.icon == nil, style: .large) + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/Protocols/IProductStyle+Compact.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/Protocols/IProductStyle+Compact.swift new file mode 100644 index 000000000..38963a578 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/Protocols/IProductStyle+Compact.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public extension IProductStyle where Self == CompactProductStyle { + static var `default`: Self { + CompactProductStyle() + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/Protocols/IProductStyle+Large.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/Protocols/IProductStyle+Large.swift new file mode 100644 index 000000000..55755a75b --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/Protocols/IProductStyle+Large.swift @@ -0,0 +1,16 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +@available(iOS 13.0, *) +@available(macOS, unavailable) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +public extension IProductStyle where Self == LargeProductStyle { + static var large: Self { + LargeProductStyle() + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/Protocols/IProductStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/Protocols/IProductStyle.swift new file mode 100644 index 000000000..2f1aeb613 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Product/Protocols/IProductStyle.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// A type that represents a product style. +public protocol IProductStyle { + /// The properties of an in-app store product. + typealias Configuration = ProductStyleConfiguration + + /// A view that represents the body of an in-app store product. + associatedtype Body: View + + /// Creates a view that represents the body of an in-app store product. + /// - Parameter configuration: The properties of an in-app store product. + @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Configuration/SubscriptionStoreControlStyleConfiguration.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Configuration/SubscriptionStoreControlStyleConfiguration.swift new file mode 100644 index 000000000..a8f6590c4 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Configuration/SubscriptionStoreControlStyleConfiguration.swift @@ -0,0 +1,58 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// swiftlint:disable:next type_name +public struct SubscriptionStoreControlStyleConfiguration { + // MARK: Types + + /// A view for the label. + public struct Label: View { + public var body: AnyView + + init(_ view: Content) { + body = view.eraseToAnyView() + } + } + + /// A view for the description. + public struct Description: View { + public var body: AnyView + + init(_ view: Content) { + body = view.eraseToAnyView() + } + } + + /// A view for the price. + public struct Price: View { + public var body: AnyView + + init(_ view: Content) { + body = view.eraseToAnyView() + } + } + + // MARK: Properties + + /// The label view. + public let label: Label + /// The description view. + public let description: Description + /// The price view. + public let price: Price + /// A Boolean value indicating whether the subscription is selected. + public let isSelected: Bool + /// A Boolean value indicating whether the subscription is active. + public let isActive: Bool + + let action: () -> Void + + /// Triggers the action associated with the subscription. + public func trigger() { + action() + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Extensions/ISubscriptionControlStyle+Bordered.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Extensions/ISubscriptionControlStyle+Bordered.swift new file mode 100644 index 000000000..3f8349e14 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Extensions/ISubscriptionControlStyle+Bordered.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +@available(watchOS, unavailable) +public extension ISubscriptionControlStyle where Self == ButtonSubscriptionStoreControlStyle { + static var button: ButtonSubscriptionStoreControlStyle { + ButtonSubscriptionStoreControlStyle() + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Extensions/ISubscriptionControlStyle+PickerSubscriptionStoreControlStyle+PickerSubscriptionStoreControlStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Extensions/ISubscriptionControlStyle+PickerSubscriptionStoreControlStyle+PickerSubscriptionStoreControlStyle.swift new file mode 100644 index 000000000..0bdc2c863 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Extensions/ISubscriptionControlStyle+PickerSubscriptionStoreControlStyle+PickerSubscriptionStoreControlStyle.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +@available(iOS 13.0, macOS 10.15, watchOS 7.0, *) +@available(tvOS, unavailable) +public extension ISubscriptionControlStyle where Self == PickerSubscriptionStoreControlStyle { + static var picker: PickerSubscriptionStoreControlStyle { + PickerSubscriptionStoreControlStyle() + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Extensions/ISubscriptionControlStyle+ProminentPickerSubscriptionStoreControlStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Extensions/ISubscriptionControlStyle+ProminentPickerSubscriptionStoreControlStyle.swift new file mode 100644 index 000000000..0558d728f --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Extensions/ISubscriptionControlStyle+ProminentPickerSubscriptionStoreControlStyle.swift @@ -0,0 +1,16 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +@available(iOS 13.0, macOS 10.15, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +public extension ISubscriptionControlStyle where Self == ProminentPickerSubscriptionStoreControlStyle { + static var prominentPicker: ProminentPickerSubscriptionStoreControlStyle { + ProminentPickerSubscriptionStoreControlStyle() + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Protocols/ISubscriptionControlStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Protocols/ISubscriptionControlStyle.swift new file mode 100644 index 000000000..fa5568eb8 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Protocols/ISubscriptionControlStyle.swift @@ -0,0 +1,20 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public protocol ISubscriptionControlStyle { + /// A view that represents the body of an in-app subscription store control. + associatedtype Body: View + + /// The properties of an in-app subscription store control. + typealias Configuration = SubscriptionStoreControlStyleConfiguration + + /// Creates a view that represents the body of an in-app subscription store control. + /// + /// - Parameters: + /// - configuration: The properties of an in-app subscription store control. + @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/AutomaticSubscriptionControlStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/AutomaticSubscriptionControlStyle.swift new file mode 100644 index 000000000..e702ce0bb --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/AutomaticSubscriptionControlStyle.swift @@ -0,0 +1,30 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - AutomaticSubscriptionControlStyle + +@available(watchOS, unavailable) +struct AutomaticSubscriptionControlStyle: ISubscriptionControlStyle { + // MARK: ISubscriptionControlStyle + + func makeBody(configuration: Configuration) -> some View { + #if os(iOS) + return ProminentPickerSubscriptionStoreControlStyle().makeBody(configuration: configuration) + #else + return ButtonSubscriptionStoreControlStyle().makeBody(configuration: configuration) + #endif + } +} + +// MARK: - Extensions + +@available(watchOS, unavailable) +extension ISubscriptionControlStyle where Self == AutomaticSubscriptionControlStyle { + static var automatic: AutomaticSubscriptionControlStyle { + AutomaticSubscriptionControlStyle() + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/BorderedSubscriptionStoreControlStyle/BorderedSubscriptionStoreControlStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/BorderedSubscriptionStoreControlStyle/BorderedSubscriptionStoreControlStyle.swift new file mode 100644 index 000000000..ac0b0825d --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/BorderedSubscriptionStoreControlStyle/BorderedSubscriptionStoreControlStyle.swift @@ -0,0 +1,43 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - BorderedSubscriptionStoreControlStyle + +@available(iOS 13.0, macOS 10.15, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +struct BorderedSubscriptionStoreControlStyle: ISubscriptionControlStyle { + // MARK: Properties + + init() {} + + // MARK: ISubscriptionControlStyle + + func makeBody(configuration: Configuration) -> some View { + BorderedSubscriptionStoreControlStyleView(configuration: configuration) + } +} + +// MARK: - Preview + +#if swift(>=5.9) && os(iOS) + #Preview { + VStack { + BorderedSubscriptionStoreControlStyle().makeBody( + configuration: .init( + label: .init(Text("Name")), + description: .init(Text("Name")), + price: .init(Text("Name")), + isSelected: true, + isActive: true, + action: {} + ) + ) + } + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/BorderedSubscriptionStoreControlStyle/BorderedSubscriptionStoreControlStyleView.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/BorderedSubscriptionStoreControlStyle/BorderedSubscriptionStoreControlStyleView.swift new file mode 100644 index 000000000..4a6095373 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/BorderedSubscriptionStoreControlStyle/BorderedSubscriptionStoreControlStyleView.swift @@ -0,0 +1,96 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - BorderedSubscriptionStoreControlStyleView + +@available(iOS 13.0, macOS 10.15, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +// swiftlint:disable:next type_name +struct BorderedSubscriptionStoreControlStyleView: View { + // MARK: Properties + + @Environment(\.subscriptionStoreButtonLabel) private var subscriptionStoreButtonLabel + @Environment(\.tintColor) private var tintColor + + private let configuration: ISubscriptionControlStyle.Configuration + + // MARK: Initialization + + init(configuration: ISubscriptionControlStyle.Configuration) { + self.configuration = configuration + } + + // MARK: View + + var body: some View { + Button(action: { + configuration.trigger() + }, label: { + labelView(configuration) + }) + .buttonStyle(PrimaryButtonStyle()) + } + + // MARK: Private + + @ViewBuilder + private func labelView(_ configuration: ISubscriptionControlStyle.Configuration) -> some View { + switch subscriptionStoreButtonLabel { + case .action: + textView + case .displayName: + configuration.label + .font(.body.weight(.bold)) + .contrast(tintColor) + case .multiline: + VStack { + configuration.label + .font(.body.weight(.bold)) + .contrast(tintColor) + + if configuration.isActive { + Text(L10n.Common.Subscription.Status.yourCurrentPlan) + .font(.footnote.weight(.medium)) + .contrast(tintColor) + } else { + configuration.price + .font(.footnote.weight(.medium)) + .contrast(tintColor) + } + } + case .price: + configuration.price + .font(.footnote.weight(.medium)) + } + } + + private var textView: some View { + VStack { + Text(L10n.Common.Subscription.Action.subscribe) + .font(.body) + .fontWeight(.bold) + .contrast(tintColor) + } + } +} + +#if swift(>=5.9) && os(iOS) + #Preview { + BorderedSubscriptionStoreControlStyleView( + configuration: .init( + label: .init(Text("Name")), + description: .init(Text("Name")), + price: .init(Text("Name")), + isSelected: true, + isActive: true, + action: {} + ) + ) + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/ButtonSubscriptionStoreControlStyle/ButtonSubscriptionStoreControlStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/ButtonSubscriptionStoreControlStyle/ButtonSubscriptionStoreControlStyle.swift new file mode 100644 index 000000000..712ff7af9 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/ButtonSubscriptionStoreControlStyle/ButtonSubscriptionStoreControlStyle.swift @@ -0,0 +1,43 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - ButtonSubscriptionStoreControlStyle + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +@available(watchOS, unavailable) +public struct ButtonSubscriptionStoreControlStyle: ISubscriptionControlStyle { + // MARK: Initialization + + public init() {} + + // MARK: ISubscriptionControlStyle + + public func makeBody(configuration: Configuration) -> some View { + #if os(tvOS) + return CardButtonSubscriptionStoreControlStyle().makeBody(configuration: configuration) + #else + return BorderedSubscriptionStoreControlStyle().makeBody(configuration: configuration) + #endif + } +} + +// MARK: - Preview + +#if swift(>=5.9) && os(iOS) + #Preview { + ButtonSubscriptionStoreControlStyle().makeBody( + configuration: .init( + label: .init(Text("Name")), + description: .init(Text("Name")), + price: .init(Text("Name")), + isSelected: true, + isActive: true, + action: {} + ) + ) + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/CardButtonSubscriptionStoreControlStyle/CardButtonSubscriptionStoreControlStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/CardButtonSubscriptionStoreControlStyle/CardButtonSubscriptionStoreControlStyle.swift new file mode 100644 index 000000000..a99b93049 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/CardButtonSubscriptionStoreControlStyle/CardButtonSubscriptionStoreControlStyle.swift @@ -0,0 +1,55 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +#if os(tvOS) + @available(tvOS 13.0, *) + @available(iOS, unavailable) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + struct CardButtonSubscriptionStoreControlStyle: ISubscriptionControlStyle { + // MARK: ISubscriptionControlStyle + + func makeBody(configuration: Configuration) -> some View { + if #available(tvOS 15.0, *) { + contentView(configuration: configuration) + .buttonStyle(CardButtonStyle()) + } else { + contentView(configuration: configuration) + } + } + + // MARK: Private + + private func contentView(configuration: Configuration) -> some View { + Button( + action: { + configuration.trigger() + }, label: { + CardButtonSubscriptionStoreControlView(configuration: configuration) + } + ) + } + } +#endif + +// MARK: - Preview + +#if swift(>=5.9) && os(tvOS) + #Preview { + CardButtonSubscriptionStoreControlStyle().makeBody( + configuration: .init( + label: .init(Text("Name")), + description: .init(Text("Name")), + price: .init(Text("Name")), + isSelected: true, + isActive: true, + action: {} + ) + ) + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/CardButtonSubscriptionStoreControlStyle/CardButtonSubscriptionStoreControlView.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/CardButtonSubscriptionStoreControlStyle/CardButtonSubscriptionStoreControlView.swift new file mode 100644 index 000000000..b3fe658de --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/CardButtonSubscriptionStoreControlStyle/CardButtonSubscriptionStoreControlView.swift @@ -0,0 +1,98 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - CardButtonSubscriptionStoreControlView + +@available(tvOS 13.0, *) +@available(iOS, unavailable) +@available(macOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +struct CardButtonSubscriptionStoreControlView: View { + // MARK: Properties + +// @Environment(\.isFocused) private var isFocused: Bool + @Environment(\.tintColor) private var tintColor + + private let configuration: SubscriptionStoreControlStyleConfiguration + + // MARK: Initialization + + init(configuration: SubscriptionStoreControlStyleConfiguration) { + self.configuration = configuration + } + + // MARK: View + + var body: some View { + ZStack { + Rectangle() + .fill(tintColor) // isFocused ? tintColor.opacity(0.85) : tintColor) + + VStack(alignment: .leading) { + VStack(alignment: .leading) { + if configuration.isActive { + planView + } + + configuration.label + .contrast(tintColor) + .font(.headline) + configuration.price + .contrast(tintColor) + .font(.caption.weight(.medium)) + .layoutPriority(1) + } + + Spacer(minLength: .zero) + .frame(maxWidth: .infinity) + + configuration.description + .contrast(tintColor) + .font(.footnote) + } + .padding() + } + .frame(minWidth: .minWidth, minHeight: .minHeight) + .fixedSize(horizontal: true, vertical: true) + } + + // MARK: Private + + private var planView: some View { + HStack { + Text(L10n.Common.Subscription.Status.yourPlan) + .opacity(0.8) + .contrast(tintColor) + .font(.caption) + } + } +} + +// MARK: - Constants + +private extension CGFloat { + static let minWidth = 528.0 + static let minHeight = 204.0 +} + +// MARK: - Preview + +#if swift(>=5.9) && os(tvOS) + #Preview { + CardButtonSubscriptionStoreControlView( + configuration: .init( + label: .init(Text("Name")), + description: .init(Text("Name")), + price: .init(Text("Name")), + isSelected: true, + isActive: true, + action: {} + ) + ) + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/PickerSubscriptionStoreControlStyle/PickerSubscriptionStoreControlStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/PickerSubscriptionStoreControlStyle/PickerSubscriptionStoreControlStyle.swift new file mode 100644 index 000000000..774872a00 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/PickerSubscriptionStoreControlStyle/PickerSubscriptionStoreControlStyle.swift @@ -0,0 +1,40 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - PickerSubscriptionStoreControlStyle + +@available(iOS 13.0, macOS 10.15, watchOS 7.0, *) +@available(tvOS, unavailable) +@available(visionOS, unavailable) +public struct PickerSubscriptionStoreControlStyle: ISubscriptionControlStyle { + // MARK: Initialization + + public init() {} + + // MARK: ISubscriptionControlStyle + + public func makeBody(configuration: Configuration) -> some View { + PickerSubscriptionStoreControlStyleView(configuration: configuration) + } +} + +// MARK: - Preview + +#if swift(>=5.9) && os(iOS) + #Preview { + PickerSubscriptionStoreControlStyle().makeBody( + configuration: .init( + label: .init(Text("Name").eraseToAnyView()), + description: .init(Text("Name").eraseToAnyView()), + price: .init(Text("Name").eraseToAnyView()), + isSelected: true, + isActive: true, + action: {} + ) + ) + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/PickerSubscriptionStoreControlStyle/PickerSubscriptionStoreControlStyleView.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/PickerSubscriptionStoreControlStyle/PickerSubscriptionStoreControlStyleView.swift new file mode 100644 index 000000000..758e4e8cb --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/PickerSubscriptionStoreControlStyle/PickerSubscriptionStoreControlStyleView.swift @@ -0,0 +1,138 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - PickerSubscriptionStoreControlStyleView + +@available(iOS 13.0, macOS 10.15, watchOS 7.0, *) +@available(tvOS, unavailable) +@available(visionOS, unavailable) +struct PickerSubscriptionStoreControlStyleView: View { + // MARK: Properties + + @Environment(\.subscriptionPickerItemBackground) private var background + @Environment(\.subscriptionViewTint) private var tintColor + + private let configuration: ISubscriptionControlStyle.Configuration + + // MARK: Initialization + + init(configuration: ISubscriptionControlStyle.Configuration) { + self.configuration = configuration + } + + // MARK: View + + var body: some View { + contentView + #if os(iOS) || os(macOS) || os(watchOS) + .onTapGesture { + configuration.trigger() + } + #endif + } + + // MARK: Private + + private var contentView: some View { + VStack(alignment: .leading, spacing: .spacing10px) { + HStack { + VStack(alignment: .leading, spacing: 8.0) { + if configuration.isActive { + planView + } + configuration.label + .font(.headline) + } + Spacer() + checkmarkView + } + + configuration.price + .font(.subheadline) + separatorView + configuration.description + .font(.subheadline) + } + .padding() + .background(background) + .mask(rectangleBackground) + } + + private var checkmarkView: some View { + ImageView( + systemName: configuration.isSelected ? .checkmark : .circle, + defaultImage: configuration.isSelected ? Media.Media.checkmark.swiftUIImage : Media.Media.circle.swiftUIImage + ) + .foregroundColor(configuration.isSelected ? tintColor : Palette.systemGray2.opacity(0.7)) + .frame( + width: CGSize.iconSize.width, + height: CGSize.iconSize.height + ) + .background(configuration.isSelected ? Color.white : .clear) + .mask(Circle()) + } + + private var separatorView: some View { + Rectangle() + .foregroundColor(Palette.systemGray4) + .frame(maxWidth: .infinity) + .frame(height: .separatorHeight) + } + + private var rectangleBackground: RoundedRectangle { + RoundedRectangle(cornerSize: .cornerSize) + } + + private var planView: some View { + HStack(spacing: 4.0) { + ImageView(systemName: .star, defaultImage: Media.Media.star.swiftUIImage) + .frame( + width: CGSize.planImageSize.width, + height: CGSize.planImageSize.height + ) + .foregroundColor(tintColor) + Text(L10n.Common.Subscription.Status.yourPlan.uppercased()) + .font(.caption.weight(.bold)) + .foregroundColor(Palette.systemGray) + } + } +} + +// MARK: - Preview + +#if swift(>=5.9) && os(iOS) + #Preview { + PickerSubscriptionStoreControlStyleView( + configuration: .init( + label: .init(Text("Name").eraseToAnyView()), + description: .init(Text("Name").eraseToAnyView()), + price: .init(Text("Name").eraseToAnyView()), + isSelected: true, + isActive: true, + action: {} + ) + ) + } +#endif + +// MARK: - Constants + +private extension String { + static let checkmark = "checkmark.circle.fill" + static let circle = "circle" + static let star = "star" +} + +private extension CGSize { + static let cornerSize = CGSize(width: 18, height: 18) + static let iconSize = CGSize(width: 26, height: 26) + static let planImageSize = CGSize(width: 14, height: 14) +} + +private extension CGFloat { + static let separatorHeight = 1.0 +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/ProminentPickerSubscriptionStoreControlStyle/ProminentPickerSubscriptionStoreControlStyle.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/ProminentPickerSubscriptionStoreControlStyle/ProminentPickerSubscriptionStoreControlStyle.swift new file mode 100644 index 000000000..a588b2a4f --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/ProminentPickerSubscriptionStoreControlStyle/ProminentPickerSubscriptionStoreControlStyle.swift @@ -0,0 +1,42 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - ProminentPickerSubscriptionStoreControlStyle + +@available(iOS 13.0, macOS 10.15, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +// swiftlint:disable:next type_name +public struct ProminentPickerSubscriptionStoreControlStyle: ISubscriptionControlStyle { + // MARK: Initialization + + public init() {} + + // MARK: ISubscriptionControlStyle + + public func makeBody(configuration: Configuration) -> some View { + ProminentPickerSubscriptionStoreControlStyleView(configuration: configuration) + } +} + +// MARK: - Preview + +#if swift(>=5.9) && os(iOS) + #Preview { + ProminentPickerSubscriptionStoreControlStyle().makeBody( + configuration: .init( + label: .init(Text("Name").eraseToAnyView()), + description: .init(Text("Name").eraseToAnyView()), + price: .init(Text("Name").eraseToAnyView()), + isSelected: true, + isActive: true, + action: {} + ) + ) + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/ProminentPickerSubscriptionStoreControlStyle/ProminentPickerSubscriptionStoreControlStyleView.swift b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/ProminentPickerSubscriptionStoreControlStyle/ProminentPickerSubscriptionStoreControlStyleView.swift new file mode 100644 index 000000000..5d7c87846 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/SubscriptionStoreControlStyle/ProminentPickerSubscriptionStoreControlStyle/ProminentPickerSubscriptionStoreControlStyleView.swift @@ -0,0 +1,52 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - ProminentPickerSubscriptionStoreControlStyleView + +@available(iOS 13.0, macOS 10.15, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +// swiftlint:disable:next type_name +struct ProminentPickerSubscriptionStoreControlStyleView: View { + // MARK: Properties + + @Environment(\.subscriptionViewTint) private var tintColor + + private let configuration: ISubscriptionControlStyle.Configuration + + // MARK: Initialization + + init(configuration: ISubscriptionControlStyle.Configuration) { + self.configuration = configuration + } + + // MARK: View + + var body: some View { + PickerSubscriptionStoreControlStyle().makeBody(configuration: configuration) + .overlay(overlayView(configuration: configuration)) + } + + // MARK: Private + + private var rectangleBackground: RoundedRectangle { + RoundedRectangle(cornerSize: .cornerSize) + } + + private func overlayView(configuration: ISubscriptionControlStyle.Configuration) -> some View { + rectangleBackground + .strokeBorder(tintColor, lineWidth: 2) + .opacity((configuration.isSelected) ? 1.0 : .zero) + } +} + +// MARK: - Constants + +private extension CGSize { + static let cornerSize = CGSize(width: 18, height: 18) +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/ActivityIndicatorModifier.swift b/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/ActivityIndicatorModifier.swift new file mode 100644 index 000000000..2e017bf92 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/ActivityIndicatorModifier.swift @@ -0,0 +1,42 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - ActivityIndicatorModifier + +struct ActivityIndicatorModifier: ViewModifier { + // MARK: Properties + + private let isLoading: Bool + + // MARK: Initialization + + init(isLoading: Bool) { + self.isLoading = isLoading + } + + // MARK: ViewModifier + + func body(content: Content) -> some View { + if isLoading { + ZStack(alignment: .center) { + content + .disabled(isLoading) + .blur(radius: self.isLoading ? 3 : 0) + + LoadingView(type: .backgrouned, message: "Purchasing the subscription...") + } + } else { + content + } + } +} + +extension View { + func activityIndicator(isLoading: Bool) -> some View { + modifier(ActivityIndicatorModifier(isLoading: isLoading)) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/BlurEffectModifier.swift b/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/BlurEffectModifier.swift new file mode 100644 index 000000000..a043c09dd --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/BlurEffectModifier.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +#if os(iOS) || os(tvOS) + struct BlurEffectModifier: ViewModifier { + init() {} + + func body(content: Content) -> some View { + content + .overlay(BlurVisualEffectView()) + } + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/ErrorAlertViewModifier.swift b/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/ErrorAlertViewModifier.swift new file mode 100644 index 000000000..d922ce432 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/ErrorAlertViewModifier.swift @@ -0,0 +1,47 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - ErrorAlertViewModifier + +/// A view modifier that handles an error alert. +struct ErrorAlertViewModifier: ViewModifier { + // MARK: Properties + + /// An error to be presented. + @Binding private var error: Error? + + /// A binding to control the presentation state of the error. + private var isErrorPresented: Binding { + Binding { error != nil } set: { _ in error = nil } + } + + // MARK: Initialization + + /// Creates an ``ErrorAlertViewModifier`` instance. + /// + /// - Parameter error: A binding to control the presentation state of the error. + init(error: Binding) { + _error = error + } + + // MARK: ViewModifier + + func body(content: Content) -> some View { + content + .alert(isPresented: isErrorPresented) { + Alert(title: Text(L10n.Error.Default.title), message: Text(error?.localizedDescription ?? "")) + } + } +} + +// MARK: - Extensions + +extension View { + func errorAlert(_ error: Binding) -> some View { + modifier(ErrorAlertViewModifier(error: error)) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/LoadViewModifier.swift b/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/LoadViewModifier.swift new file mode 100644 index 000000000..bf095ecd6 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/LoadViewModifier.swift @@ -0,0 +1,47 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - LoadViewModifier + +/// A view modifier that triggers a handler once when view is loaded. +struct LoadViewModifier: ViewModifier { + // MARK: Private + + /// A Bool value that indicates the view is loaded. + @State private var isLoaded = false + + /// A handler closure. + private let handler: () -> Void + + // MARK: Initialization + + /// Creates a ``LoadViewModifier`` instance. + /// + /// - Parameter handler: A handler closure to be performed when view is loaded. + init(handler: @escaping () -> Void) { + self.handler = handler + } + + // MARK: ViewModifier + + func body(content: Content) -> some View { + content.onAppear { + if !isLoaded { + handler() + isLoaded.toggle() + } + } + } +} + +// MARK: - Extensions + +extension View { + func onLoad(_ handler: @escaping () -> Void) -> some View { + modifier(LoadViewModifier(handler: handler)) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/PaywallViewModifier.swift b/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/PaywallViewModifier.swift new file mode 100644 index 000000000..3bdfbc8ee --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/ViewModifiers/PaywallViewModifier.swift @@ -0,0 +1,44 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// A view modifier provides a paywall functionality. +@available(watchOS, unavailable) +struct PaywallViewModifier: ViewModifier { + // MARK: Properties + + /// The paywall type. + private let paywallType: PaywallType + /// The binding to control the presentation state of the paywall. + private let presented: Binding + + // MARK: Initialization + + /// Creates a `PaywallViewModifier` instance. + /// + /// - Parameters: + /// - paywallType: The paywall type. + /// - presented: The binding to control the presentation state of the paywall. + init( + paywallType: PaywallType, + presented: Binding + ) { + self.paywallType = paywallType + self.presented = presented + } + + // MARK: ViewModifier + + func body(content: Content) -> some View { + content + .sheet(isPresented: presented) { + ZStack { + Palette.systemBackground.edgesIgnoringSafeArea(.all) + PaywallView(paywallType: paywallType) + } + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Views/ActivityIndicator/ActivityIndicatorView.swift b/Sources/FlareUI/Classes/Presentation/Components/Views/ActivityIndicator/ActivityIndicatorView.swift new file mode 100644 index 000000000..9ba4db625 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Views/ActivityIndicator/ActivityIndicatorView.swift @@ -0,0 +1,69 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI +#if canImport(UIKit) + import UIKit +#elseif canImport(Cocoa) + import Cocoa +#endif + +#if os(iOS) || os(tvOS) + typealias ViewRepresentable = UIViewRepresentable +#elseif os(macOS) + typealias ViewRepresentable = NSViewRepresentable +#elseif os(watchOS) + typealias ViewRepresentable = WKInterfaceObjectRepresentable +#endif + +// MARK: - ActivityIndicatorView + +#if os(iOS) || os(tvOS) || os(macOS) + struct ActivityIndicatorView: ViewRepresentable { + // MARK: Properties + + @Binding var isAnimating: Bool + + #if os(iOS) || os(tvOS) + let style: UIActivityIndicatorView.Style + #endif + + // MARK: UIViewRepresentable + + #if os(macOS) + func makeNSView(context _: Context) -> NSProgressIndicator { + let progressIndicator = NSProgressIndicator() + progressIndicator.style = .spinning + progressIndicator.usesThreadedAnimation = true + return progressIndicator + } + + func updateNSView(_ nsView: NSViewType, context _: Context) { + if isAnimating { + nsView.startAnimation(nil) + } else { + nsView.stopAnimation(nil) + } + } + #endif + + #if os(iOS) || os(tvOS) + func makeUIView(context _: UIViewRepresentableContext) -> UIActivityIndicatorView { + UIActivityIndicatorView(style: style) + } + + func updateUIView( + _ uiView: UIActivityIndicatorView, + context _: UIViewRepresentableContext + ) { + if isAnimating { + uiView.startAnimating() + } else { + uiView.stopAnimating() + } + } + #endif + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Views/BlurVisualEffectView/BlurVisualEffectView.swift b/Sources/FlareUI/Classes/Presentation/Components/Views/BlurVisualEffectView/BlurVisualEffectView.swift new file mode 100644 index 000000000..706958829 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Views/BlurVisualEffectView/BlurVisualEffectView.swift @@ -0,0 +1,27 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - BlurVisualEffectView + +#if os(iOS) || os(tvOS) + struct BlurVisualEffectView: UIViewRepresentable { + func makeUIView(context: Context) -> UIVisualEffectView { + UIVisualEffectView(effect: UIBlurEffect(style: context.environment.blurEffectStyle)) + } + + func updateUIView(_ uiView: UIVisualEffectView, context: Context) { + uiView.effect = UIBlurEffect(style: context.environment.blurEffectStyle) + } + } + + extension View { + /// Creates a blur effect. + func blurEffect() -> some View { + ModifiedContent(content: self, modifier: BlurEffectModifier()) + } + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Components/Views/ImageView/ImageView.swift b/Sources/FlareUI/Classes/Presentation/Components/Views/ImageView/ImageView.swift new file mode 100644 index 000000000..31dbbb352 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Views/ImageView/ImageView.swift @@ -0,0 +1,34 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +struct ImageView: View { + // MARK: Properties + + private let systemName: String + private let defaultImage: Image + + // MARK: Initialization + + init(systemName: String, defaultImage: Image) { + self.systemName = systemName + self.defaultImage = defaultImage + } + + // MARK: View + + var body: some View { + Group { + if #available(macOS 11.0, iOS 14.0, tvOS 14.0, *) { + Image(systemName: systemName) + .resizable() + } else { + defaultImage + .resizable() + } + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Views/ProductPlaceholderView/ProductPlaceholderView.swift b/Sources/FlareUI/Classes/Presentation/Components/Views/ProductPlaceholderView/ProductPlaceholderView.swift new file mode 100644 index 000000000..837d41e1e --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Views/ProductPlaceholderView/ProductPlaceholderView.swift @@ -0,0 +1,185 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - ProductPlaceholderView + +struct ProductPlaceholderView: View { + // MARK: Types + + enum Style { + @available(iOS 13.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + case large + case compact + } + + // MARK: Properties + + private let isIconHidden: Bool + private let style: Style + + // MARK: Initialization + + init(isIconHidden: Bool, style: Style) { + self.isIconHidden = isIconHidden + self.style = style + } + + // MARK: View + + var body: some View { + contentView + } + + // MARK: Private + + private var contentView: some View { + stackView(spacing: .iconSpacing) { + if !isIconHidden { + iconView + } + stackView(spacing: 8.0) { + textView + Spacer() + buttonView + } + .padding(.padding) + } + .background(value(default: Color.clear, tvOS: Palette.dynamicBackground)) + .frame(height: style == .compact ? metrics(compact: .height, large: nil) : nil) + .frame(maxHeight: metrics(compact: .height, large: .largeHeight)) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, value(default: .spacing, tvOS: .zero)) + } + + private var textView: some View { + VStack( + alignment: metrics(compact: .leading, large: .center), + spacing: .spacing + ) { + Palette.gray + .frame( + idealWidth: .titleWidth, + maxWidth: .titleWidth, + idealHeight: .titleHeight, + maxHeight: .titleHeight + ) + .mask(RoundedRectangle(cornerRadius: .cornerRadius)) + Palette.gray + .frame( + idealWidth: .descriptionWidth, + maxWidth: .descriptionWidth, + idealHeight: .descriptionHeight, + maxHeight: .descriptionHeight + ) + .mask(RoundedRectangle(cornerRadius: .cornerRadius)) + #if os(tvOS) + Spacer() + #endif + } + } + + private var buttonView: some View { + Palette.gray + .frame( + idealWidth: .buttonWidth, + maxWidth: .buttonWidth, + idealHeight: .buttonHeight, + maxHeight: .buttonHeight + ) + .mask(buttonMask) + } + + private var iconView: some View { + Palette.gray + .frame( + idealWidth: metrics(compact: .iconWidth, large: .iconLargeWidth), + maxWidth: metrics(compact: .iconWidth, large: .iconLargeWidth), + idealHeight: metrics(compact: .iconHeight, large: .iconLargeHeight), + maxHeight: metrics(compact: .iconHeight, large: .iconLargeHeight) + ) + } + + private func metrics(compact: T, large: T?) -> T { + #if os(iOS) + switch style { + case .compact: + return compact + case .large: + return large ?? compact + } + #else + return compact + #endif + } + + private var buttonMask: some View { + #if os(tvOS) + RoundedRectangle(cornerRadius: .zero) + #else + Capsule() + #endif + } + + @ViewBuilder + private func stackView(spacing: CGFloat = .zero, @ViewBuilder content: () -> some View) -> some View { + #if os(iOS) + Group { + switch style { + case .large: + VStack(spacing: spacing) { + content() + } + case .compact: + HStack(spacing: spacing) { + content() + } + } + } + #else + HStack(spacing: spacing) { + content() + } + #endif + } +} + +// MARK: Preview + +#if swift(>=5.9) + #Preview { + Group { + ForEach(0 ..< 10) { _ in + ProductPlaceholderView(isIconHidden: true, style: .compact) + ProductPlaceholderView(isIconHidden: false, style: .compact) + } + } + } +#endif + +// MARK: Constants + +private extension CGFloat { + static let padding = value(default: .zero, tvOS: 24.0) + static let spacing = value(default: 2.0, tvOS: 10.0) + static let height = value(default: 60.0, tvOS: 200.0) + static let largeHeight = value(default: 200.0) + static let titleWidth = value(default: 123.0, tvOS: 240.0) + static let titleHeight = value(default: 20.0, tvOS: 30.0) + static let descriptionWidth = value(default: 208.0, tvOS: 180.0) + static let descriptionHeight = value(default: 14.0, tvOS: 24.0) + static let buttonWidth = value(default: 76.0, tvOS: 90.0) + static let buttonHeight = value(default: 34.0, tvOS: 25) + static let cornerRadius = value(default: CGFloat.cornerRadius4px, tvOS: .zero) + static let iconWidth = value(default: 60.0, tvOS: 330.0) + static let iconHeight = value(default: 60.0, tvOS: 200.0) + static let iconLargeWidth = value(default: 105.0) + static let iconLargeHeight = value(default: 105.0) + static let iconSpacing = value(default: 8.0, tvOS: .zero) +} diff --git a/Sources/FlareUI/Classes/Presentation/Components/Views/SafariWebView/SafariWebView.swift b/Sources/FlareUI/Classes/Presentation/Components/Views/SafariWebView/SafariWebView.swift new file mode 100644 index 000000000..b598e0bf2 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Views/SafariWebView/SafariWebView.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +#if os(iOS) + import SafariServices + import SwiftUI + + struct SafariWebView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context _: Context) -> SFSafariViewController { + SFSafariViewController(url: url) + } + + func updateUIViewController(_: SFSafariViewController, context _: Context) {} + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Helpers/ViewWrapper.swift b/Sources/FlareUI/Classes/Presentation/Helpers/ViewWrapper.swift new file mode 100644 index 000000000..c25f52115 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Helpers/ViewWrapper.swift @@ -0,0 +1,38 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - IViewWrapper + +/// A type defines a wrapper for a view. +protocol IViewWrapper: View { + associatedtype ViewModel + + /// Creates a new `IViewWrapper` instance. + /// + /// - Parameter viewModel: The view model. + init(viewModel: ViewModel) +} + +// MARK: - ViewWrapper + +struct ViewWrapper: View where ViewWrapper.ViewModel == ViewModel { + // MARK: Properties + + @ObservedObject private var viewModel: WrapperViewModel + + // MARK: Initialization + + init(viewModel: WrapperViewModel) { + self.viewModel = viewModel + } + + // MARK: IViewWrapper + + var body: some View { + ViewWrapper(viewModel: viewModel.model) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Helpers/WrapperViewModel.swift b/Sources/FlareUI/Classes/Presentation/Helpers/WrapperViewModel.swift new file mode 100644 index 000000000..48aee03f1 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Helpers/WrapperViewModel.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// An observable view model. +final class WrapperViewModel: ObservableObject { + // MARK: Properties + + /// The model object. + @Published var model: T + + // MARK: Initialization + + /// Creates a `ViewModel` instance. + /// + /// - Parameter model: The model object. + init(model: T) { + self.model = model + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/PaywallView/PaywallView.swift b/Sources/FlareUI/Classes/Presentation/Views/PaywallView/PaywallView.swift new file mode 100644 index 000000000..120f5ddb4 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/PaywallView/PaywallView.swift @@ -0,0 +1,37 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(watchOS, unavailable) +struct PaywallView: View { + // MARK: Properties + + private let presentationAssembly: IPresentationAssembly + private let paywallType: PaywallType + + // MARK: Initialization + + init( + paywallType: PaywallType, + presentationAssembly: IPresentationAssembly = PresentationAssembly() + ) { + self.paywallType = paywallType + self.presentationAssembly = presentationAssembly + } + + // MARK: View + + var body: some View { + switch paywallType { + case let .subscriptions(productIDs): + let productIDs: any Collection = productIDs + presentationAssembly.subscritpionsViewAssembly.assemble(ids: productIDs) + case let .products(productIDs): + let productIDs: any Collection = productIDs + presentationAssembly.productsViewAssembly.assemble(ids: productIDs) + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/PoliciesButtonAssembly.swift b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/PoliciesButtonAssembly.swift new file mode 100644 index 000000000..cac5bba4d --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/PoliciesButtonAssembly.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - IPoliciesButtonAssembly + +@available(watchOS, unavailable) +protocol IPoliciesButtonAssembly { + func assemble() -> PoliciesButtonView +} + +// MARK: - PoliciesButtonAssembly + +@available(watchOS, unavailable) +final class PoliciesButtonAssembly: IPoliciesButtonAssembly { + func assemble() -> PoliciesButtonView { + PoliciesButtonView() + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/PoliciesButtonView.swift b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/PoliciesButtonView.swift new file mode 100644 index 000000000..d17073248 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/PoliciesButtonView.swift @@ -0,0 +1,120 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - PoliciesButtonView + +@available(watchOS, unavailable) +struct PoliciesButtonView: View { + // MARK: Types + + private enum LinkType { + case termsOfService + case privacyPolicy + } + + // MARK: Private + + @State private var link: LinkType? + private var isPresented: Binding { + Binding { link != nil } set: { _ in link = nil } + } + + @Environment(\.subscriptionTermsOfServiceURL) private var subscriptionTermsOfServiceURL + @Environment(\.subscriptionPrivacyPolicyURL) private var subscriptionPrivacyPolicyURL + @Environment(\.subscriptionTermsOfServiceDestination) private var subscriptionTermsOfServiceDestination + @Environment(\.subscriptionPrivacyPolicyDestination) private var subscriptionPrivacyPolicyDestination + + @Environment(\.policiesButtonStyle) private var policiesButtonStyle + + // MARK: View + + var body: some View { + policiesButtonStyle.makeBody( + configuration: .init( + termsOfUseView: .init(termsOfServiceButton), + privacyPolicyView: .init(privacyPolicyButton) + ) + ) + .font(.footnote) + .sheet(isPresented: isPresented) { + contentView + } + } + + // MARK: Private + + private var termsOfServiceButton: some View { + Button(action: { + link = .termsOfService + }, label: { + Text(L10n.Common.termsOfService) + }) + } + + private var privacyPolicyButton: some View { + Button(action: { + link = .privacyPolicy + }, label: { + Text(L10n.Common.privacyPolicy) + }) + } + + private var contentView: some View { + link.map { link in + Group { + switch link { + case .privacyPolicy: + privacyPolicyContentView + case .termsOfService: + privacyTermsOfServiceView + } + } + } + } + + @ViewBuilder + private var privacyPolicyContentView: some View { + if subscriptionPrivacyPolicyDestination != nil { + subscriptionPrivacyPolicyDestination + } else if let subscriptionPrivacyPolicyURL { + #if os(iOS) + safariView(subscriptionPrivacyPolicyURL) + #endif + } else { + PoliciesUnavailableView(type: .privacyPolicy) + } + } + + @ViewBuilder + private var privacyTermsOfServiceView: some View { + if subscriptionTermsOfServiceDestination != nil { + subscriptionTermsOfServiceDestination + } else if let subscriptionTermsOfServiceURL { + #if os(iOS) + safariView(subscriptionTermsOfServiceURL) + #endif + } else { + PoliciesUnavailableView(type: .termsOfService) + } + } + + #if os(iOS) + @available(iOS 13.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + private func safariView(_ url: URL) -> some View { + SafariWebView(url: url).edgesIgnoringSafeArea(.all) + } + #endif +} + +#if swift(>=5.9) && os(iOS) + #Preview { + PoliciesButtonView() + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/AnyPoliciesButtonStyle.swift b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/AnyPoliciesButtonStyle.swift new file mode 100644 index 000000000..8b1d20ab0 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/AnyPoliciesButtonStyle.swift @@ -0,0 +1,34 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +struct AnyPoliciesButtonStyle: IPoliciesButtonStyle { + // MARK: Properties + + let style: any IPoliciesButtonStyle + + /// A private property to hold the closure that creates the body of the view + private var _makeBody: (Configuration) -> AnyView + + // MARK: Initialization + + /// Initializes the `AnyPoliciesButtonStyle` with a specific style conforming to `IPoliciesButtonStyle`. + /// + /// - Parameter style: A product style. + init(style: S) { + self.style = style + _makeBody = { configuration in + AnyView(style.makeBody(configuration: configuration)) + } + } + + // MARK: IPoliciesButtonStyle + + /// Implements the makeBody method required by `IPoliciesButtonStyle`. + func makeBody(configuration: Configuration) -> some View { + _makeBody(configuration) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/AutomaticPoliciesButtonStyle/AutomaticPoliciesButtonStyle.swift b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/AutomaticPoliciesButtonStyle/AutomaticPoliciesButtonStyle.swift new file mode 100644 index 000000000..6e876282e --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/AutomaticPoliciesButtonStyle/AutomaticPoliciesButtonStyle.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(watchOS, unavailable) +struct AutomaticPoliciesButtonStyle: IPoliciesButtonStyle { + func makeBody(configuration: Configuration) -> some View { + #if os(tvOS) + return TVPoliciesButtonStyle().makeBody(configuration: configuration) + #else + return DefaultPoliciesButtonStyle().makeBody(configuration: configuration) + #endif + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/Configuration/PoliciesButtonStyleConfiguration.swift b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/Configuration/PoliciesButtonStyleConfiguration.swift new file mode 100644 index 000000000..3cccb72a4 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/Configuration/PoliciesButtonStyleConfiguration.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +struct PoliciesButtonStyleConfiguration { + // MARK: Types + + struct ButtonView: View { + var body: AnyView + + init(_ view: Content) { + body = view.eraseToAnyView() + } + } + + // MARK: Properties + + let termsOfUseView: ButtonView + let privacyPolicyView: ButtonView +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/DefaultPoliciesButtonStyle/DefaultPoliciesButtonStyle.swift b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/DefaultPoliciesButtonStyle/DefaultPoliciesButtonStyle.swift new file mode 100644 index 000000000..a76af39f4 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/DefaultPoliciesButtonStyle/DefaultPoliciesButtonStyle.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(iOS 13.0, macOS 10.15, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +struct DefaultPoliciesButtonStyle: IPoliciesButtonStyle { + func makeBody(configuration: Configuration) -> some View { + DefaultPoliciesButtonStyleView(configuration: configuration) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/DefaultPoliciesButtonStyle/DefaultPoliciesButtonStyleView.swift b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/DefaultPoliciesButtonStyle/DefaultPoliciesButtonStyleView.swift new file mode 100644 index 000000000..6bd003ee9 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/DefaultPoliciesButtonStyle/DefaultPoliciesButtonStyleView.swift @@ -0,0 +1,42 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - DefaultPoliciesButtonStyleView + +struct DefaultPoliciesButtonStyleView: View { + // MARK: Properties + + private let configuration: PoliciesButtonStyleConfiguration + + @Environment(\.tintColor) private var tintColor + + // MARK: Initialization + + init(configuration: PoliciesButtonStyleConfiguration) { + self.configuration = configuration + } + + // MARK: View + + var body: some View { + HStack(spacing: .spacing) { + configuration.termsOfUseView + .foregroundColor(tintColor) + + Text(L10n.Common.Words.and) + + configuration.privacyPolicyView + .foregroundColor(tintColor) + } + } +} + +// MARK: - Constants + +private extension CGFloat { + static let spacing = 3.0 +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/IPoliciesButtonStyle.swift b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/IPoliciesButtonStyle.swift new file mode 100644 index 000000000..bd14daf07 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/IPoliciesButtonStyle.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +protocol IPoliciesButtonStyle { + /// A view that represents the body of an in-app subscription store control. + associatedtype Body: View + + /// The properties of an in-app subscription store control. + typealias Configuration = PoliciesButtonStyleConfiguration + + /// Creates a view that represents the body of an in-app subscription store control. + /// + /// - Parameter configuration: The properties of an in-app subscription store control. + @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/TVPoliciesButtonStyle/TVPoliciesButtonStyle.swift b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/TVPoliciesButtonStyle/TVPoliciesButtonStyle.swift new file mode 100644 index 000000000..15208de85 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Styles/TVPoliciesButtonStyle/TVPoliciesButtonStyle.swift @@ -0,0 +1,27 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - TVPoliciesButtonStyle + +@available(tvOS 13.0, *) +@available(macOS, unavailable) +@available(watchOS, unavailable) +@available(iOS, unavailable) +struct TVPoliciesButtonStyle: IPoliciesButtonStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: .spacing) { + configuration.termsOfUseView + configuration.privacyPolicyView + } + } +} + +// MARK: - Constants + +private extension CGFloat { + static let spacing = 60.0 +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Views/PoliciesUnavailableView.swift b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Views/PoliciesUnavailableView.swift new file mode 100644 index 000000000..fc32bd885 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/PoliciesButtonAssembly/Views/PoliciesUnavailableView.swift @@ -0,0 +1,66 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - PoliciesUnavailableView + +struct PoliciesUnavailableView: View { + // MARK: Types + + enum PolicyType { + case privacyPolicy + case termsOfService + + var title: String { + switch self { + case .privacyPolicy: + return L10n.Policies.Unavailable.PrivacyPolicy.title + case .termsOfService: + return L10n.Policies.Unavailable.TermsOfService.title + } + } + + var message: String { + switch self { + case .privacyPolicy: + return L10n.Policies.Unavailable.PrivacyPolicy.message + case .termsOfService: + return L10n.Policies.Unavailable.TermsOfService.message + } + } + } + + // MARK: Properties + + private let type: PolicyType + + // MARK: Initialization + + init(type: PolicyType) { + self.type = type + } + + // MARK: View + + var body: some View { + VStack { + Text(type.title) + .font(.title) + .multilineTextAlignment(.center) + Text(type.message) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(Palette.systemGray) + } + .padding() + } +} + +#if swift(>=5.9) + #Preview { + StoreUnavaliableView(productType: .product) + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductPresenter.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductPresenter.swift new file mode 100644 index 000000000..102df915d --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductPresenter.swift @@ -0,0 +1,83 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation +import SwiftUI + +// MARK: - IProductPresenter + +/// A protocol for presenting product information and handling purchases. +protocol IProductPresenter { + /// Called when the view has loaded. + func viewDidLoad() + + /// Initiates a purchase for the product with optional purchase options asynchronously. + /// + /// - Parameter options: Optional purchase options. + /// + /// - Returns: A `StoreTransaction` representing the purchase transaction. + func purchase(options: PurchaseOptions?) async throws -> StoreTransaction +} + +// MARK: - ProductPresenter + +/// The presenter for a product. +final class ProductPresenter: IPresenter { + // MARK: Properties + + /// The strategy for fetching product information. + private let productFetcher: IProductFetcherStrategy + /// The service for handling product purchases. + private let purchaseService: IProductPurchaseService + + /// The view model. + weak var viewModel: WrapperViewModel? + + // MARK: Initialization + + /// Initializes the presenter with the given dependencies. + /// + /// - Parameters: + /// - productFetcher: The strategy for fetching product information. + /// - purchaseService: The service for handling product purchases. + init( + productFetcher: IProductFetcherStrategy, + purchaseService: IProductPurchaseService + ) { + self.productFetcher = productFetcher + self.purchaseService = purchaseService + } +} + +// MARK: IProductPresenter + +extension ProductPresenter: IProductPresenter { + func viewDidLoad() { + update(state: .loading) + + Task { @MainActor in + do { + let product = try await productFetcher.product() + self.update(state: .product(product)) + } catch { + if let error = error as? IAPError { + self.update(state: .error(error)) + } else { + self.update(state: .error(IAPError.with(error: error))) + } + } + } + } + + @MainActor + func purchase(options: PurchaseOptions?) async throws -> StoreTransaction { + guard case let .product(product) = viewModel?.model.state else { + throw IAPError.unknown + } + + return try await purchaseService.purchase(product: product, options: options) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductPurchaseService.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductPurchaseService.swift new file mode 100644 index 000000000..f8cd61dc0 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductPurchaseService.swift @@ -0,0 +1,69 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +// MARK: - IProductPurchaseService + +/// A protocol for handling product purchases. +protocol IProductPurchaseService { + /// Purchases a product with optional purchase options asynchronously. + /// + /// - Parameters: + /// - product: The product to purchase. + /// - options: Optional purchase options. + /// + /// - Returns: A `StoreTransaction` representing the purchase transaction. + func purchase(product: StoreProduct, options: PurchaseOptions?) async throws -> StoreTransaction +} + +// MARK: - ProductPurchaseService + +/// An actor for handling product purchases. +actor ProductPurchaseService: IProductPurchaseService { + // MARK: Types + + enum Error: Swift.Error { + case alreadyExecuted + } + + // MARK: Properties + + /// The in-app purchase service. + private var iap: IFlare + + private var isExecuted = false + + // MARK: Initialization + + /// Initializes the purchase service with the given dependencies. + /// + /// - Parameters: + /// - iap: The in-app purchase service. + init(iap: IFlare) { + self.iap = iap + } + + // MARK: IPurchaseService + + func purchase(product: StoreProduct, options: PurchaseOptions?) async throws -> StoreTransaction { + guard !isExecuted else { throw Error.alreadyExecuted } + + isExecuted = true + defer { isExecuted = false } + + return try await _purchase(product: product, options: options) + } + + // MARK: Private + + private func _purchase(product: StoreProduct, options: PurchaseOptions?) async throws -> StoreTransaction { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), let options = options?.options { + return try await iap.purchase(product: product, options: options) + } else { + return try await iap.purchase(product: product) + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductView.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductView.swift new file mode 100644 index 000000000..b5c3072e0 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductView.swift @@ -0,0 +1,56 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// A view for displaying a product. +/// +/// A `ProductView` shows information about an in-app purchase product, including its localized name, description, +/// and price, and displays a purchase button. +/// +/// You create a product view by providing a product identifier to load from the App Store. If you provide a product identifier, +/// the view loads the product’s information from the App Store automatically, and updates the view when the product is available. +/// +/// You can customize the product view’s appearance using the standard styles, including the ``LargeProductStyle`` and +/// ``CompactProductStyle`` styles. Apply the style using the ``SwiftUI/View/productViewStyle(_:)``. +/// +/// You can also create your own custom styles by creating styles that conform to the ``IProductStyle`` protocol. +/// +/// ## Example ## +/// +/// ```swift +/// struct AppProductView: View { +/// var body: some View { +/// ProductView(id: "com.company.app.product_id") +/// } +/// } +/// ``` +@available(iOS 13.0, tvOS 13.0, macOS 10.15, *) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +public struct ProductView: View { + // MARK: Properties + + /// The presentation assembly for creating views. + private let presentationAssembly = PresentationAssembly() + + /// The ID of the product to display. + private let id: String + + // MARK: Initialization + + /// Initializes the product view with the given ID. + /// + /// - Parameter id: The ID of the product to display. + public init(id: String) { + self.id = id + } + + // MARK: View + + public var body: some View { + presentationAssembly.productViewAssembly.assemble(id: id) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewAssembly.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewAssembly.swift new file mode 100644 index 000000000..0aab6b95a --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewAssembly.swift @@ -0,0 +1,69 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import SwiftUI + +// MARK: - IProductViewAssembly + +/// A protocol for assembling product views. +protocol IProductViewAssembly { + /// Assembles a product view for the given product ID. + /// + /// - Parameter id: The ID of the product. + /// - Returns: A `ViewWrapper` containing the product view model and the product wrapper view. + func assemble(id: String) -> ViewWrapper + + /// Assembles a product view for the given store product. + /// + /// - Parameter storeProduct: The store product. + /// - Returns: A `ViewWrapper` containing the product view model and the product wrapper view. + func assemble(storeProduct: StoreProduct) -> ViewWrapper +} + +// MARK: - ProductViewAssembly + +/// An assembly class for creating product views. +final class ProductViewAssembly: IProductViewAssembly { + // MARK: Properties + + private let iap: IFlare + + // MARK: Initialization + + /// Initializes the assembly with the given in-app purchase service. + /// + /// - Parameter iap: The in-app purchase service. + init(iap: IFlare) { + self.iap = iap + } + + // MARK: IProductViewAssembly + + func assemble(id: String) -> ViewWrapper { + assemble(with: .productID(id)) + } + + func assemble(storeProduct: StoreProduct) -> ViewWrapper { + assemble(with: .product(storeProduct)) + } + + // MARK: Private + + private func assemble(with type: ProductViewType) -> ViewWrapper { + let presenter = ProductPresenter( + productFetcher: ProductStrategy(type: type, iap: iap), + purchaseService: ProductPurchaseService(iap: iap) + ) + let viewModel = WrapperViewModel( + model: ProductViewModel(state: .loading, presenter: presenter) + ) + presenter.viewModel = viewModel + + return ViewWrapper( + viewModel: viewModel + ) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewModel.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewModel.swift new file mode 100644 index 000000000..09b7283f1 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewModel.swift @@ -0,0 +1,37 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - ProductViewModel + +/// A view model for managing a product. +struct ProductViewModel: IModel { + /// The state of the view model. + enum State: Equatable { + /// Loading state. + case loading + /// Loaded product state. + case product(StoreProduct) + /// Error state. + case error(IAPError) + } + + /// The current state of the view model. + let state: State + /// The presenter for the product. + let presenter: IProductPresenter +} + +extension ProductViewModel { + /// Sets the state of the view model and returns a new instance with the updated state. + /// + /// - Parameter state: The new state of the view model. + /// - Returns: A new `ProductViewModel` instance with the updated state. + func setState(_ state: State) -> ProductViewModel { + ProductViewModel(state: state, presenter: presenter) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewModelFactory.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewModelFactory.swift new file mode 100644 index 000000000..f94e4b811 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewModelFactory.swift @@ -0,0 +1,60 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - IProductViewModelFactory + +protocol IProductViewModelFactory { + func make(_ product: StoreProduct, style: ProductStyle) -> ProductInfoView.ViewModel +} + +// MARK: - ProductViewModelFactory + +final class ProductViewModelFactory: IProductViewModelFactory { + // MARK: Properties + + private let subscriptionPriceViewModelFactory: ISubscriptionPriceViewModelFactory + + // MARK: Initialization + + init( + subscriptionPriceViewModelFactory: ISubscriptionPriceViewModelFactory = SubscriptionPriceViewModelFactory() + ) { + self.subscriptionPriceViewModelFactory = subscriptionPriceViewModelFactory + } + + // MARK: IProductViewModelFactory + + func make(_ product: StoreProduct, style: ProductStyle) -> ProductInfoView.ViewModel { + ProductInfoView.ViewModel( + id: product.productIdentifier, + title: product.localizedTitle, + description: product.localizedDescription, + price: makePrice(from: product, style: style), + priceDescription: makePriceDescription(from: product) + ) + } + + // MARK: Private + + private func makePrice(from product: StoreProduct, style: ProductStyle) -> String { + switch style { + case .compact: + return subscriptionPriceViewModelFactory.make(product, format: .short) + case .large: + return subscriptionPriceViewModelFactory.make(product, format: .full) + } + } + + private func makePriceDescription(from product: StoreProduct) -> String? { + let localizedPeriod = subscriptionPriceViewModelFactory.period(from: product) + + guard let string = localizedPeriod?.words.last else { return nil } + + return L10n.Product.priceDescription(string).capitalized + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewType.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewType.swift new file mode 100644 index 000000000..c1dab94f8 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewType.swift @@ -0,0 +1,14 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +/// An enumeration representing different types of product views. +enum ProductViewType { + /// A product view initialized with a `StoreProduct`. + case product(StoreProduct) + /// A product view initialized with a product ID. + case productID(String) +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductWrapperView.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductWrapperView.swift new file mode 100644 index 000000000..b1ffff511 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductWrapperView.swift @@ -0,0 +1,77 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import SwiftUI + +// MARK: - ProductWrapperView + +struct ProductWrapperView: View, IViewWrapper { + // MARK: Properties + + @Environment(\.productViewStyle) var productViewStyle + @Environment(\.purchaseCompletion) var purchaseCompletion + @Environment(\.purchaseOptions) var purchaseOptions + + @State private var error: Error? + @State private var isExecuted = false + + private let viewModel: ProductViewModel + + // MARK: Initialization + + init(viewModel: ProductViewModel) { + self.viewModel = viewModel + } + + // MARK: View + + var body: some View { + contentView + .onLoad { viewModel.presenter.viewDidLoad() } + .errorAlert($error) + } + + // MARK: Private + + @ViewBuilder + private var contentView: some View { + switch viewModel.state { + case .loading: + productViewStyle.makeBody(configuration: .init(state: .loading)) + case let .product(storeProduct): + productViewStyle.makeBody( + configuration: .init( + state: .product(item: storeProduct), + purchase: purchase + ) + ) + case let .error(error): + productViewStyle.makeBody(configuration: .init(state: .error(error: error))) + } + } + + private func purchase() { + guard case let .product(storeProduct) = viewModel.state, !isExecuted else { return } + + isExecuted = true + + Task { @MainActor in + defer { isExecuted = false } + + do { + let options = purchaseOptions?(storeProduct) + let transaction = try await viewModel.presenter.purchase(options: options) + + purchaseCompletion?(storeProduct, .success(transaction)) + } catch { + if error.iap != .paymentCancelled { + self.error = error.iap + purchaseCompletion?(storeProduct, .failure(error)) + } + } + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductView/Strategies/ProductStrategy.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductView/Strategies/ProductStrategy.swift new file mode 100644 index 000000000..841e1f119 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/Strategies/ProductStrategy.swift @@ -0,0 +1,43 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +// MARK: - IProductFetcherStrategy + +protocol IProductFetcherStrategy { + func product() async throws -> StoreProduct +} + +// MARK: - ProductStrategy + +final class ProductStrategy: IProductFetcherStrategy { + // MARK: Properties + + private let iap: IFlare + private let type: ProductViewType + + // MARK: Initialization + + init(type: ProductViewType, iap: IFlare) { + self.type = type + self.iap = iap + } + + // MARK: IProductStrategy + + func product() async throws -> StoreProduct { + switch type { + case let .productID(id): + let product = try await iap.fetch(productIDs: [id]).first + + guard let product else { throw IAPError.storeProductNotAvailable } + + return product + case let .product(product): + return product + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductView/SubscriptionDateComponentsFactory.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductView/SubscriptionDateComponentsFactory.swift new file mode 100644 index 000000000..0487c6abd --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/SubscriptionDateComponentsFactory.swift @@ -0,0 +1,37 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - ISubscriptionDateComponentsFactory + +protocol ISubscriptionDateComponentsFactory { + func dateComponents(for subscription: SubscriptionPeriod) -> DateComponents +} + +// MARK: - SubscriptionDateComponentsFactory + +final class SubscriptionDateComponentsFactory: ISubscriptionDateComponentsFactory { + func dateComponents(for subscription: SubscriptionPeriod) -> DateComponents { + var dateComponents = DateComponents() + dateComponents.calendar = Calendar.current + + let numberOfUnits = subscription.value + + switch subscription.unit { + case .day: + dateComponents.setValue(numberOfUnits, for: .day) + case .week: + dateComponents.setValue(numberOfUnits, for: .weekOfMonth) + case .month: + dateComponents.setValue(numberOfUnits, for: .month) + case .year: + dateComponents.setValue(numberOfUnits, for: .year) + } + + return dateComponents + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductView/Views/ProductInfoView/ProductInfoView.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductView/Views/ProductInfoView/ProductInfoView.swift new file mode 100644 index 000000000..3fdd89851 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/Views/ProductInfoView/ProductInfoView.swift @@ -0,0 +1,207 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - ProductInfoView + +struct ProductInfoView: View { + // MARK: Types + + enum Style { + @available(iOS 13.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + case large + case compact + } + + // MARK: Properties + + private let viewModel: ViewModel + private let icon: ProductStyleConfiguration.Icon? + private let style: Style + private let action: () -> Void + + // MARK: Initialization + + init( + viewModel: ViewModel, + icon: ProductStyleConfiguration.Icon?, + style: Style, + action: @escaping () -> Void + ) { + self.viewModel = viewModel + self.icon = icon + self.style = style + self.action = action + } + + // MARK: View + + var body: some View { + #if os(tvOS) + Button(action: { + action() + }, label: { + contentView + }) + #else + contentView + #endif + } + + // MARK: Private + + private var contentView: some View { + stackView(spacing: metrics(compact: nil, large: .largeSpacing)) { + iconView + + textView + Spacer(minLength: .zero) + priceView + } + .padding(.padding) + .frame(height: .height) + } + + private var iconView: some View { + icon.map { $0 } + .frame( + idealHeight: metrics(compact: nil, large: .largeImageHeight), + maxHeight: metrics(compact: nil, large: .largeImageHeight) + ) + } + + private var textView: some View { + let alignment: HorizontalAlignment = { + switch style { + case .compact: + return .leading + case .large: + return .center + } + }() + + return VStack(alignment: alignment) { + Text(viewModel.title) + .lineLimit(.lineLimit) + .font(.body) + Text(viewModel.description) + .lineLimit(.lineLimit) + .font(.caption) + .foregroundColor(Palette.systemGray) + #if os(tvOS) + Spacer() + #endif + } + } + + private var priceView: some View { + #if os(tvOS) + Text(viewModel.price) + #else + VStack(alignment: .center) { + Button( + action: { + action() + }, + label: { + Text(viewModel.price) + .font(.subheadline) + .fontWeight(.bold) + } + ) + .buttonStyle(BorderedButtonStyle()) + + if style == .compact { + priceDescriptionView + } + } + #endif + } + + private var priceDescriptionView: some View { + viewModel.priceDescription.map { + Text($0) + .font(.system(size: .priceDescriptionFontSize)) + .foregroundColor(Palette.systemGray) + } + } + + private func stackView(spacing: CGFloat? = nil, @ViewBuilder content: () -> some View) -> some View { + Group { + switch style { + case .compact: + HStack(alignment: .center, spacing: spacing) { + content() + } + case .large: + VStack(alignment: .center, spacing: spacing) { + content() + } + } + } + } + + private func metrics(compact: T?, large: T?) -> T? { + #if os(iOS) + switch style { + case .compact: + return compact + case .large: + return large ?? compact + } + #else + return compact + #endif + } +} + +// MARK: ProductInfoView.ViewModel + +extension ProductInfoView { + struct ViewModel: Identifiable { + let id: String + let title: String + let description: String + let price: String + let priceDescription: String? + } +} + +// MARK: - Constants + +private extension CGFloat { + static let height = value(default: 56, tvOS: 200.0) + static let padding = value(default: .zero, tvOS: 24.0) + static let priceDescriptionFontSize = value(default: 10.0) + static let largeImageHeight = 140.0 + static let largeSpacing = 14.0 +} + +private extension Int { + static let lineLimit = 2 +} + +// MARK: Preview + +#if swift(>=5.9) + #Preview { + ProductInfoView( + viewModel: .init( + id: UUID().uuidString, + title: "My App Lifetime", + description: "Lifetime access to additional content", + price: "$19.99", + priceDescription: "Every Month" + ), + icon: nil, + style: .compact, + action: {} + ) + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsPresenter.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsPresenter.swift new file mode 100644 index 000000000..49862f91d --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsPresenter.swift @@ -0,0 +1,56 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - IProductsPresenter + +/// A protocol for the presenter of products. +protocol IProductsPresenter { + /// Called when the view has loaded. + func viewDidLoad() +} + +// MARK: - ProductsPresenter + +/// The presenter for products. +final class ProductsPresenter: IPresenter { + // MARK: Properties + + /// The collection of product IDs. + private let ids: any Collection + /// The in-app purchase service. + private let iap: IFlare + + weak var viewModel: WrapperViewModel? + + // MARK: Initialization + + /// Initializes the presenter with the given dependencies. + /// + /// - Parameters: + /// - ids: The collection of product IDs. + /// - iap: The in-app purchase service. + init(ids: some Collection, iap: IFlare) { + self.ids = ids + self.iap = iap + } +} + +// MARK: IProductsPresenter + +extension ProductsPresenter: IProductsPresenter { + func viewDidLoad() { + Task { @MainActor in + do { + let products = try await iap.fetch(productIDs: ids) + self.update(state: .products(products)) + } catch { + self.update(state: .error(error.iap)) + } + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsView.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsView.swift new file mode 100644 index 000000000..be8d7a96b --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsView.swift @@ -0,0 +1,54 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// A view for displaying multiple products. +/// +/// A `ProductsView` display a collection of in-app purchase products, iincluding their localized names, +/// descriptions, prices, and displays a purchase button. +/// +/// ## Customize the products view ## +/// +/// You can customize the store by displaying additional buttons, and applying styles. +/// +/// You can change the product style using ``SwiftUI/View/productViewStyle(_:)``. +/// +/// ## Example ## +/// +/// ```swift +/// struct PaywallView: View { +/// var body: some View { +/// ProductsView(ids: ["com.company.app.product_id"]) +/// } +/// } +/// ``` +@available(iOS 13.0, tvOS 13.0, macOS 10.15, *) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +public struct ProductsView: View { + // MARK: Properties + + /// The presentation assembly for creating views. + private let presentationAssembly = PresentationAssembly() + + /// The IDs of the products to display. + private let ids: any Collection + + // MARK: Initialization + + /// Initializes the products view with the given IDs. + /// + /// - Parameter ids: The IDs of the products to display. + public init(ids: some Collection) { + self.ids = ids + } + + // MARK: View + + public var body: some View { + presentationAssembly.productsViewAssembly.assemble(ids: ids) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsViewAssembly.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsViewAssembly.swift new file mode 100644 index 000000000..9598b1192 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsViewAssembly.swift @@ -0,0 +1,68 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import SwiftUI + +// MARK: - IProductsViewAssembly + +/// A protocol for assembling the view for products. +protocol IProductsViewAssembly { + /// Assembles the view for products. + /// + /// - Parameter ids: The collection of product IDs. + /// + /// - Returns: The assembled view as `AnyView`. + func assemble(ids: some Collection) -> AnyView +} + +// MARK: - ProductsViewAssembly + +/// An assembly class for creating the view for products. +final class ProductsViewAssembly: IProductsViewAssembly { + // MARK: Properties + + /// The assembly for product views. + private let storeButtonsAssembly: IStoreButtonsAssembly + /// The assembly for store buttons. + private let productAssembly: IProductViewAssembly + /// The in-app purchase service. + private let iap: IFlare + + // MARK: Initialization + + /// Initializes the `ProductsViewAssembly` with the given dependencies. + /// + /// - Parameters: + /// - productAssembly: The assembly for product views. + /// - storeButtonsAssembly: The assembly for store buttons. + /// - iap: The in-app purchase service. + init(productAssembly: IProductViewAssembly, storeButtonsAssembly: IStoreButtonsAssembly, iap: IFlare) { + self.productAssembly = productAssembly + self.storeButtonsAssembly = storeButtonsAssembly + self.iap = iap + } + + // MARK: IProductsViewAssembly + + func assemble(ids: some Collection) -> AnyView { + let presenter = ProductsPresenter( + ids: ids, + iap: iap + ) + let viewModel = WrapperViewModel( + model: ProductsViewModel( + state: .loading(ids.count), + presenter: presenter + ) + ) + presenter.viewModel = viewModel + + return ViewWrapper(viewModel: viewModel) + .environment(\.productViewAssembly, productAssembly) + .environment(\.storeButtonsAssembly, storeButtonsAssembly) + .eraseToAnyView() + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsViewModel.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsViewModel.swift new file mode 100644 index 000000000..6163368ed --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsViewModel.swift @@ -0,0 +1,40 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - ProductsViewModel + +/// A view model for managing products. +struct ProductsViewModel: IModel { + /// The state of the view model. + enum State: Equatable { + /// Loading state with progress. + case loading(Int) + /// Loaded products. + case products([StoreProduct]) + /// Error state. + case error(IAPError) + } + + /// The current state of the view model. + let state: State + /// The presenter for the products. + let presenter: IProductsPresenter +} + +extension ProductsViewModel { + /// Sets the state of the view model and returns a new instance with the updated state. + /// + /// - Parameter state: The new state of the view model. + /// - Returns: A new `ProductsViewModel` instance with the updated state. + func setState(_ state: State) -> ProductsViewModel { + ProductsViewModel( + state: state, + presenter: presenter + ) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsWrapperView.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsWrapperView.swift new file mode 100644 index 000000000..fe31bd0ea --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsWrapperView.swift @@ -0,0 +1,72 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - ProductsWrapperView + +struct ProductsWrapperView: View, IViewWrapper { + // MARK: Properties + + @Environment(\.productViewStyle) var productViewStyle + @Environment(\.storeButton) var storeButton + @Environment(\.productViewAssembly) var productViewAssembly + @Environment(\.storeButtonsAssembly) var storeButtonsAssembly + + private let viewModel: ProductsViewModel + + // MARK: Initialization + + init(viewModel: ProductsViewModel) { + self.viewModel = viewModel + } + + // MARK: View + + var body: some View { + contentView + .onAppear { viewModel.presenter.viewDidLoad() } + .padding() + } + + // MARK: Private + + @ViewBuilder + private var contentView: some View { + switch viewModel.state { + case let .loading(numberOfItems): + contentView { + ForEach(0 ..< numberOfItems, id: \.self) { _ in + productViewStyle.makeBody(configuration: .init(state: .loading)) + } + } + case let .products(products): + contentView { + ForEach(Array(products), id: \.self) { product in + productViewAssembly.map { $0.assemble(storeProduct: product) } + } + } + case .error: + StoreUnavaliableView(productType: .product) + } + } + + private var storeButtonView: some View { + ForEach(storeButton, id: \.self) { type in + storeButtonsAssembly.map { $0.assemble(storeButtonType: type) } + } + } + + private func contentView(@ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .center) { + ScrollView { + content() + .padding(.horizontal) + }.animation(nil, value: UUID()) + Spacer() + storeButtonView + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/ProductsView/Views/StoreUnavaliableView.swift b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/Views/StoreUnavaliableView.swift new file mode 100644 index 000000000..6ef24c0b1 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/Views/StoreUnavaliableView.swift @@ -0,0 +1,56 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - StoreUnavaliableView + +struct StoreUnavaliableView: View { + // MARK: Types + + enum ProductType { + case product + case subscription + + var message: String { + switch self { + case .product: + return L10n.StoreUnavailable.Product.message + case .subscription: + return L10n.StoreUnavailable.Subscription.message + } + } + } + + // MARK: Properties + + private let productType: ProductType + + // MARK: Initialization + + init(productType: ProductType) { + self.productType = productType + } + + // MARK: View + + var body: some View { + VStack { + Text(L10n.StoreUnavailable.title) + .font(.title) + Text(productType.message) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(Palette.systemGray) + } + .padding() + } +} + +#if swift(>=5.9) + #Preview { + StoreUnavaliableView(productType: .product) + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButton.swift b/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButton.swift new file mode 100644 index 000000000..b73f8d72b --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButton.swift @@ -0,0 +1,11 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +enum StoreButton { + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + case restore +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButtonAssembly.swift b/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButtonAssembly.swift new file mode 100644 index 000000000..caff51ba6 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButtonAssembly.swift @@ -0,0 +1,53 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - IStoreButtonAssembly + +protocol IStoreButtonAssembly { + func assemble(storeButtonType: StoreButton) -> ViewWrapper +} + +// MARK: - StoreButtonAssembly + +final class StoreButtonAssembly: IStoreButtonAssembly { + // MARK: Properties + + private let iap: IFlare + + // MARK: Initialization + + init(iap: IFlare) { + self.iap = iap + } + + // MARK: IStoreButtonAssembly + + func assemble(storeButtonType: StoreButton) -> ViewWrapper { + let presenter = StoreButtonPresenter(iap: iap) + let viewModel = WrapperViewModel( + model: StoreButtonViewModel( + state: map(storeButtonType: storeButtonType), + presenter: presenter + ) + ) + presenter.viewModel = viewModel + + return ViewWrapper( + viewModel: viewModel + ) + } + + // MARK: Private + + private func map(storeButtonType: StoreButton) -> StoreButtonViewModel.State { + switch storeButtonType { + case .restore: + return .restore(viewModel: .init(title: L10n.StoreButton.restorePurchases)) + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButtonPresenter.swift b/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButtonPresenter.swift new file mode 100644 index 000000000..b70e69778 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButtonPresenter.swift @@ -0,0 +1,45 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - IStoreButtonPresenter + +protocol IStoreButtonPresenter { + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func restore() async throws +} + +// MARK: - StoreButtonPresenter + +final class StoreButtonPresenter: IPresenter { + // MARK: Properties + + private let iap: IFlare + + weak var viewModel: WrapperViewModel? + + // MARK: Initialization + + init(iap: IFlare) { + self.iap = iap + } +} + +// MARK: IStoreButtonPresenter + +extension StoreButtonPresenter: IStoreButtonPresenter { + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func restore() async throws { + do { + try await iap.restore() + } catch { + if let error = error as? IAPError, error != .paymentCancelled { + throw error + } + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButtonView.swift b/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButtonView.swift new file mode 100644 index 000000000..6e06319b7 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButtonView.swift @@ -0,0 +1,50 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - StoreButtonView + +struct StoreButtonView: View, IViewWrapper { + // MARK: Properties + + @Environment(\.storeButtonViewFontWeight) private var storeButtonViewFontWeight + + private let viewModel: StoreButtonViewModel + @State private var error: Error? + + // MARK: Initialization + + init(viewModel: StoreButtonViewModel) { + self.viewModel = viewModel + } + + // MARK: View + + var body: some View { + contentView + .errorAlert($error) + } + + // MARK: Private + + @ViewBuilder + private var contentView: some View { + Button(action: { + Task { + do { + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + try await viewModel.presenter.restore() + } + } catch { + self.error = error + } + } + }, label: { + Text(viewModel.state.title) + .fontWeight(storeButtonViewFontWeight) + }) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButtonViewModel.swift b/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButtonViewModel.swift new file mode 100644 index 000000000..69997b04d --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/StoreButtonView/StoreButtonViewModel.swift @@ -0,0 +1,34 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - StoreButtonViewModel + +struct StoreButtonViewModel: IModel { + struct ViewModel: Equatable { + let title: String + } + + enum State: Equatable { + case restore(viewModel: ViewModel) + + var title: String { + switch self { + case let .restore(viewModel): + return viewModel.title + } + } + } + + let state: State + let presenter: IStoreButtonPresenter +} + +extension StoreButtonViewModel { + func setState(_ state: State) -> StoreButtonViewModel { + StoreButtonViewModel(state: state, presenter: presenter) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/StoreButtonsView/StoreButtonsAssembly.swift b/Sources/FlareUI/Classes/Presentation/Views/StoreButtonsView/StoreButtonsAssembly.swift new file mode 100644 index 000000000..749d17819 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/StoreButtonsView/StoreButtonsAssembly.swift @@ -0,0 +1,44 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - IStoreButtonsAssembly + +protocol IStoreButtonsAssembly { + func assemble(storeButtonType: StoreButtonType) -> AnyView +} + +// MARK: - StoreButtonsAssembly + +@available(watchOS, unavailable) +final class StoreButtonsAssembly: IStoreButtonsAssembly { + // MARK: Properties + + private let storeButtonAssembly: IStoreButtonAssembly + private let policiesButtonAssembly: IPoliciesButtonAssembly + + // MARK: Initialization + + init(storeButtonAssembly: IStoreButtonAssembly, policiesButtonAssembly: IPoliciesButtonAssembly) { + self.storeButtonAssembly = storeButtonAssembly + self.policiesButtonAssembly = policiesButtonAssembly + } + + // MARK: IStoreButtonsAssembly + + func assemble(storeButtonType: StoreButtonType) -> AnyView { + switch storeButtonType { + case .restore: + return Group { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + storeButtonAssembly.assemble(storeButtonType: .restore) + } + }.eraseToAnyView() + case .policies: + return policiesButtonAssembly.assemble().eraseToAnyView() + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/AnySubscriptionsWrapperViewStyle.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/AnySubscriptionsWrapperViewStyle.swift new file mode 100644 index 000000000..c30945dc9 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/AnySubscriptionsWrapperViewStyle.swift @@ -0,0 +1,32 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(watchOS, unavailable) +struct AnySubscriptionsWrapperViewStyle: ISubscriptionsWrapperViewStyle { + // MARK: Properties + + /// A private property to hold the closure that creates the body of the view + private var _makeBody: (Configuration) -> AnyView + + // MARK: Initialization + + /// Initializes the `AnyProductStyle` with a specific style conforming to `IProductStyle`. + /// + /// - Parameter style: A product style. + init(style: S) { + _makeBody = { configuration in + AnyView(style.makeBody(configuration: configuration)) + } + } + + // MARK: IProductStyle + + /// Implements the makeBody method required by `IProductStyle`. + func makeBody(configuration: Configuration) -> some View { + _makeBody(configuration) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Configuration/SubscriptionsWrapperViewStyleConfiguration.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Configuration/SubscriptionsWrapperViewStyleConfiguration.swift new file mode 100644 index 000000000..4d0510c36 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Configuration/SubscriptionsWrapperViewStyleConfiguration.swift @@ -0,0 +1,26 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(watchOS, unavailable) +// swiftlint:disable:next type_name +struct SubscriptionsWrapperViewStyleConfiguration { + // MARK: Types + + struct Toolbar: View { + let body: AnyView + + init(_ content: Content) { + self.body = content.eraseToAnyView() + } + } + + // MARK: Properties + + let items: [SubscriptionView.ViewModel] + let selectedID: String? + let action: (SubscriptionView.ViewModel) -> Void +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/ISubscriptionsWrapperViewStyle.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/ISubscriptionsWrapperViewStyle.swift new file mode 100644 index 000000000..5d45683cd --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/ISubscriptionsWrapperViewStyle.swift @@ -0,0 +1,20 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(watchOS, unavailable) +protocol ISubscriptionsWrapperViewStyle { + /// A view that represents the body of an in-app subscription store control. + associatedtype Body: View + + /// The properties of an in-app subscription store control. + typealias Configuration = SubscriptionsWrapperViewStyleConfiguration + + /// Creates a view that represents the body of an in-app subscription store control. + /// + /// - Parameter configuration: The properties of an in-app subscription store control. + @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Automatic/AutomaticSubscriptionsWrapperViewStyle.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Automatic/AutomaticSubscriptionsWrapperViewStyle.swift new file mode 100644 index 000000000..01137928b --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Automatic/AutomaticSubscriptionsWrapperViewStyle.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(watchOS, unavailable) +struct AutomaticSubscriptionsWrapperViewStyle: ISubscriptionsWrapperViewStyle { + func makeBody(configuration: Configuration) -> some View { + #if os(iOS) || os(macOS) + return FullSubscriptionsWrapperViewStyle().makeBody(configuration: configuration) + #else + return CompactSubscriptionWrapperViewStyle().makeBody(configuration: configuration) + #endif + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Compact/CompactSubscriptionWrapperView.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Compact/CompactSubscriptionWrapperView.swift new file mode 100644 index 000000000..a5548ecc1 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Compact/CompactSubscriptionWrapperView.swift @@ -0,0 +1,46 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(tvOS 13.0, *) +@available(macOS, unavailable) +@available(iOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +struct CompactSubscriptionWrapperView: View { + // MARK: Properties + + @Environment(\.subscriptionMarketingContent) private var subscriptionMarketingContent + + private let configuration: SubscriptionsWrapperViewStyleConfiguration + + // MARK: Initialization + + init(configuration: SubscriptionsWrapperViewStyleConfiguration) { + self.configuration = configuration + } + + // MARK: View + + var body: some View { + VStack { + subscriptionMarketingContent.map { content in + content.frame(maxWidth: .infinity, minHeight: 150.0) + } + + ScrollView(.horizontal) { + HStack { + ForEach(configuration.items) { item in + SubscriptionView(viewModel: item, isSelected: .constant(false)) { + configuration.action(item) + } + } + .frame(maxHeight: .infinity) + } + } + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Compact/CompactSubscriptionWrapperViewStyle.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Compact/CompactSubscriptionWrapperViewStyle.swift new file mode 100644 index 000000000..0c10d60c2 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Compact/CompactSubscriptionWrapperViewStyle.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(tvOS 13.0, *) +@available(macOS, unavailable) +@available(iOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +struct CompactSubscriptionWrapperViewStyle: ISubscriptionsWrapperViewStyle { + func makeBody(configuration: Configuration) -> some View { + CompactSubscriptionWrapperView(configuration: configuration) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Full/FullSubscriptionsWrapperView.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Full/FullSubscriptionsWrapperView.swift new file mode 100644 index 000000000..17ba05c45 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Full/FullSubscriptionsWrapperView.swift @@ -0,0 +1,59 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(iOS 13.0, macOS 10.15, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +struct FullSubscriptionsWrapperView: View { + // MARK: Properties + + @Environment(\.subscriptionMarketingContent) private var subscriptionMarketingContent + @Environment(\.subscriptionBackground) private var subscriptionBackground + + private let configuration: SubscriptionsWrapperViewStyleConfiguration + + // MARK: Initialization + + init(configuration: SubscriptionsWrapperViewStyleConfiguration) { + self.configuration = configuration + } + + // MARK: View + + var body: some View { + VStack(spacing: .zero) { + productsView(products: configuration.items) + } + .background(subscriptionBackground.edgesIgnoringSafeArea(.all)) + } + + // MARK: Private + + private func productsView(products: [SubscriptionView.ViewModel]) -> some View { + VStack(alignment: .center, spacing: .zero) { + GeometryReader { geo in + ScrollView(.vertical) { + SubscriptionHeaderView(topInset: geo.safeAreaInsets.top) + + VStack { + ForEach(products) { viewModel in + SubscriptionView( + viewModel: viewModel, + isSelected: .constant(viewModel.id == configuration.selectedID) + ) { + self.configuration.action(viewModel) + } + .padding(.horizontal) + } + } + .padding(.bottom) + } + .edgesIgnoringSafeArea(subscriptionMarketingContent != nil ? .top : []) + } + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Full/FullSubscriptionsWrapperViewStyle.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Full/FullSubscriptionsWrapperViewStyle.swift new file mode 100644 index 000000000..c3e094750 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Styles/SubscriptionsWrapperViewStyle/Styles/Full/FullSubscriptionsWrapperViewStyle.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(iOS 13.0, macOS 10.15, *) +@available(watchOS, unavailable) +@available(tvOS, unavailable) +struct FullSubscriptionsWrapperViewStyle: ISubscriptionsWrapperViewStyle { + func makeBody(configuration: Configuration) -> some View { + FullSubscriptionsWrapperView(configuration: configuration) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsAssembly.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsAssembly.swift new file mode 100644 index 000000000..c90e05e7b --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsAssembly.swift @@ -0,0 +1,56 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import SwiftUI + +// MARK: - ISubscriptionsAssembly + +protocol ISubscriptionsAssembly { + func assemble(ids: some Collection) -> AnyView +} + +// MARK: - SubscriptionsAssembly + +@available(watchOS, unavailable) +final class SubscriptionsAssembly: ISubscriptionsAssembly { + // MARK: Properties + + private let iap: IFlare + private let storeButtonsAssembly: IStoreButtonsAssembly + private let subscriptionStatusVerifierProvider: ISubscriptionStatusVerifierProvider + + // MARK: Initialization + + init( + iap: IFlare, + storeButtonsAssembly: IStoreButtonsAssembly, + subscriptionStatusVerifierProvider: ISubscriptionStatusVerifierProvider + ) { + self.iap = iap + self.storeButtonsAssembly = storeButtonsAssembly + self.subscriptionStatusVerifierProvider = subscriptionStatusVerifierProvider + } + + // MARK: ISubscriptionAssembly + + func assemble(ids: some Collection) -> AnyView { + let viewModelFactory = SubscriptionsViewModelViewFactory( + subscriptionStatusVerifier: subscriptionStatusVerifierProvider.subscriptionStatusVerifier + ) + let presenter = SubscriptionsPresenter(iap: iap, ids: ids, viewModelFactory: viewModelFactory) + let viewModel = WrapperViewModel( + model: SubscriptionsViewModel( + state: .loading, + selectedProductID: ids.first, + presenter: presenter + ) + ) + presenter.viewModel = viewModel + return ViewWrapper(viewModel: viewModel) + .environment(\.storeButtonsAssembly, storeButtonsAssembly) + .eraseToAnyView() + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsPresenter.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsPresenter.swift new file mode 100644 index 000000000..69335ec47 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsPresenter.swift @@ -0,0 +1,125 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation +import StoreKit + +// MARK: - ISubscriptionsPresenter + +/// A protocol for presenting subscription information and handling subscriptions. +protocol ISubscriptionsPresenter { + /// Called when the view has loaded. + func viewDidLoad() + + /// Selects a product with the specified ID. + /// + /// - Parameter id: The ID of the product to select. + func selectProduct(with id: String) + + /// Retrieves the product with the specified ID. + /// + /// - Parameter id: The ID of the product to retrieve. + /// - Returns: The store product corresponding to the ID, or `nil` if not found. + func product(withID id: String) -> StoreProduct? + + /// Initiates a subscription with optional purchase options asynchronously. + /// + /// - Parameter optionsHandler: The handler for purchase options. + /// - Returns: A `StoreTransaction` representing the subscription transaction. + func subscribe(optionsHandler: PurchaseOptionHandler?) async throws -> StoreTransaction +} + +// MARK: - SubscriptionsPresenter + +/// A presenter for managing subscriptions. +@available(watchOS, unavailable) +final class SubscriptionsPresenter: IPresenter { + // MARK: Properties + + /// The in-app purchase service. + private let iap: IFlare + /// The collection of subscription IDs. + private let ids: any Collection + /// The factory for creating view models and views. + private let viewModelFactory: ISubscriptionsViewModelViewFactory + + private var products: [StoreProduct] = [] + + weak var viewModel: WrapperViewModel? + + // MARK: Initialization + + /// Initializes the presenter with the given dependencies. + /// + /// - Parameters: + /// - iap: The in-app purchase service. + /// - ids: The collection of subscription IDs. + /// - viewModelFactory: The factory for creating view models and views. + init(iap: IFlare, ids: some Collection, viewModelFactory: ISubscriptionsViewModelViewFactory) { + self.iap = iap + self.ids = ids + self.viewModelFactory = viewModelFactory + } + + // MARK: Private + + private func loadProducts() { + Task { @MainActor in + do { + self.products = try await iap.fetch(productIDs: ids) + .filter { $0.productType == .autoRenewableSubscription } + + guard !products.isEmpty else { + update(state: .error(.storeProductNotAvailable)) + return + } + + let viewModel = try await viewModelFactory.make(products) + + update(state: .products(viewModel)) + } catch { + update(state: .error(error.iap)) + } + } + } +} + +// MARK: ISubscriptionsPresenter + +@available(watchOS, unavailable) +extension SubscriptionsPresenter: ISubscriptionsPresenter { + func viewDidLoad() { + loadProducts() + } + + func product(withID id: String) -> StoreProduct? { + products.by(id: id) + } + + func selectProduct(with id: String) { + guard let model = self.viewModel?.model else { return } + self.viewModel?.model = model.setSelectedProductID(id) + } + + func subscribe(optionsHandler: PurchaseOptionHandler?) async throws -> StoreTransaction { + guard let id = self.viewModel?.model.selectedProductID, let product = products.by(id: id) else { + throw IAPError.unknown + } + + let options = optionsHandler?(product) + let transaction: StoreTransaction + + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), let options = options?.options { + transaction = try await iap.purchase(product: product, options: options) + } else { + transaction = try await iap.purchase(product: product) + } + + loadProducts() + + return transaction + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsView.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsView.swift new file mode 100644 index 000000000..e4f91dc85 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsView.swift @@ -0,0 +1,86 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +/// A view for displaying subscriptions. +/// +/// A `SubscriptionsView` displays localized information about auto-renewable subscriptions, +/// including their localized names, descriptions, prices, and a purchase button. +/// +/// ## Provide a background and a decorative icon ## +/// +/// The subscription view draws a default background by default. You can add a background as follows: +/// - To set the container background of the subscriptions view using a color, use the ``SwiftUI/View/subscriptionBackground(_:)`` +/// - To set the header backgroubd of the subscriptions view using a color, use the +/// ``SwiftUI/View/subscriptionHeaderContentBackground(_:)`` +/// - To set a marketing header content, use the ``SwiftUI/View/subscriptionMarketingContent(view:)`` +/// +/// ## Provide a custom buttons appearance ## +/// +/// The `SubscriptionsView` can display subscription buttons in various forms. You can change the buttons appearance using +/// ``SwiftUI/View/subscriptionButtonLabel(_:)`` or you can change the form of buttons using ``SwiftUI/View/subscriptionControlStyle(_:)``. +/// +/// ## Handle purchase events ## +/// +/// The subscription view provides an easy way to handle purchase events as follows: +/// - To handle the completion of a purchase event, use the ``SwiftUI/View/onInAppPurchaseCompletion(completion:)`` +/// - To pass an additional parameters to a purchase event, use the ``SwiftUI/View/inAppPurchaseOptions(_:)`` +/// +/// ## Customizing Behavior ## +/// +/// The `SubscriptionsView` draws a pin if the subscription is active. You can customize this behavior by passing a custom +/// ``ISubscriptionStatusVerifier`` inside ``UIConfiguration`` to ``FlareUI``. +/// +/// ## Add terms of service and privacy policy ## +/// +/// The `SubscriptionView` can display buttons for terms of service and privacy policy. You can provide either URLs or custom views with +/// this information. You can do this using modifiers +/// - ``SwiftUI/View/subscriptionTermsOfServiceURL(_:)`` +/// - ``SwiftUI/View/subscriptionTermsOfServiceDestination(content:)`` +/// - ``SwiftUI/View/subscriptionPrivacyPolicyURL(_:)`` +/// - ``SwiftUI/View/subscriptionPrivacyPolicyDestination(content:)`` +/// +/// ## Add auxiliary buttons ## +/// +/// The `SubscriptionView` can display auxiliary buttons like Restore Purchases. You can specify button visibility within the subscription +/// view using ``SwiftUI/View/storeButton(_:types:)-1eqh``. +/// +/// # Example # +/// +/// ```swift +/// struct PaywallView: View { +/// var body: some View { +/// SubscriptionsView(ids: ["com.company.app.subscription"]) +/// } +/// } +/// ``` +@available(iOS 13.0, tvOS 13.0, macOS 11.0, *) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +public struct SubscriptionsView: View { + // MARK: Properties + + /// The presentation assembly for creating views. + private let presentationAssembly = PresentationAssembly() + + /// The IDs of the subscriptions to display. + private let ids: any Collection + + // MARK: Initialization + + /// Initializes the subscriptions view with the given IDs. + /// + /// - Parameter ids: The IDs of the subscriptions to display. + public init(ids: some Collection) { + self.ids = ids + } + + // MARK: View + + public var body: some View { + presentationAssembly.subscritpionsViewAssembly.assemble(ids: ids) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsViewModel.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsViewModel.swift new file mode 100644 index 000000000..ae2f29bc5 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsViewModel.swift @@ -0,0 +1,65 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - SubscriptionsViewModel + +/// A view model for managing subscriptions. +@available(watchOS, unavailable) +struct SubscriptionsViewModel: IModel { + /// The state of the view model. + enum State: Equatable { + /// Loading state. + case loading + /// Loaded products state. + case products([SubscriptionView.ViewModel]) + /// Error state. + case error(IAPError) + } + + /// The current state of the view model. + let state: State + /// The selected product ID. + let selectedProductID: String? + /// The presenter for the subscriptions. + let presenter: ISubscriptionsPresenter + + /// The number of products in the loaded state. + var numberOfProducts: Int { + if case let .products(array) = state { + return array.count + } + return 0 + } +} + +@available(watchOS, unavailable) +extension SubscriptionsViewModel { + /// Sets the state of the view model and returns a new instance with the updated state. + /// + /// - Parameter state: The new state of the view model. + /// - Returns: A new `SubscriptionsViewModel` instance with the updated state. + func setState(_ state: State) -> SubscriptionsViewModel { + SubscriptionsViewModel( + state: state, + selectedProductID: selectedProductID, + presenter: presenter + ) + } + + /// Sets the selected product ID and returns a new instance with the updated selected product ID. + /// + /// - Parameter selectedProductID: The selected product ID. + /// - Returns: A new `SubscriptionsViewModel` instance with the updated selected product ID. + func setSelectedProductID(_ selectedProductID: String?) -> SubscriptionsViewModel { + SubscriptionsViewModel( + state: state, + selectedProductID: selectedProductID, + presenter: presenter + ) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsViewModelViewFactory.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsViewModelViewFactory.swift new file mode 100644 index 000000000..c7429772b --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsViewModelViewFactory.swift @@ -0,0 +1,69 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - ISubscriptionsViewModelViewFactory + +@available(watchOS, unavailable) +protocol ISubscriptionsViewModelViewFactory { + func make(_ products: [StoreProduct]) async throws -> [SubscriptionView.ViewModel] +} + +// MARK: - SubscriptionsViewModelViewFactory + +@available(watchOS, unavailable) +final class SubscriptionsViewModelViewFactory: ISubscriptionsViewModelViewFactory { + // MARK: Properties + + private let subscriptionPriceViewModelFactory: ISubscriptionPriceViewModelFactory + private let subscriptionStatusVerifier: ISubscriptionStatusVerifier? + + // MARK: Initialization + + init( + subscriptionPriceViewModelFactory: ISubscriptionPriceViewModelFactory = SubscriptionPriceViewModelFactory(), + subscriptionStatusVerifier: ISubscriptionStatusVerifier? = nil + ) { + self.subscriptionPriceViewModelFactory = subscriptionPriceViewModelFactory + self.subscriptionStatusVerifier = subscriptionStatusVerifier + } + + // MARK: ISubscriptionsViewModelViewFactory + + func make(_ products: [StoreProduct]) async throws -> [SubscriptionView.ViewModel] { + var viewModels: [SubscriptionView.ViewModel] = [] + + for product in products { + let viewModel = try SubscriptionView.ViewModel( + id: product.productIdentifier, + title: product.localizedTitle, + price: makePrice(string: subscriptionPriceViewModelFactory.make(product, format: .full)), + description: product.localizedDescription, + isActive: await validationSubscriptionStatus(product) + ) + + viewModels.append(viewModel) + } + + return viewModels + } + + // MARK: Private + + private func validationSubscriptionStatus(_ product: StoreProduct) async throws -> Bool { + guard let subscriptionStatusVerifier = subscriptionStatusVerifier else { return false } + return try await subscriptionStatusVerifier.validate(product) + } + + private func makePrice(string: String) -> String { + #if os(tvOS) + return L10n.Subscriptions.Renewable.subscriptionDescriptionSeparated(string) + #else + return string + #endif + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsWrapperView.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsWrapperView.swift new file mode 100644 index 000000000..9e2b014cd --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsWrapperView.swift @@ -0,0 +1,136 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(watchOS, unavailable) +struct SubscriptionsWrapperView: View, IViewWrapper { + // MARK: Propertirs + + @Environment(\.subscriptionsWrapperViewStyle) private var subscriptionsWrapperViewStyle + + @Environment(\.purchaseCompletion) private var purchaseCompletion + @Environment(\.purchaseOptions) private var purchaseOptions + @Environment(\.subscriptionBackground) private var subscriptionBackground + @Environment(\.subscriptionControlStyle) private var subscriptionControlStyle + @Environment(\.storeButtonsAssembly) private var storeButtonsAssembly + @Environment(\.storeButton) private var storeButton + + @State private var selectedProduct: SubscriptionView.ViewModel? + @State private var error: Error? + + @State private var isLoading = false + + private var isButtonsStyle: Bool { + subscriptionControlStyle.style is ButtonSubscriptionStoreControlStyle + } + + private let viewModel: SubscriptionsViewModel + + // MARK: Initialization + + init(viewModel: SubscriptionsViewModel) { + self.viewModel = viewModel + } + + // MARK: View + + var body: some View { + contentView + .onAppear { viewModel.presenter.viewDidLoad() } + .errorAlert($error) + } + + // MARK: Private + + @ViewBuilder + private var contentView: some View { + switch viewModel.state { + case .loading: + loadingView + case let .products(products): + VStack(spacing: .zero) { + productsView(products: products) + .onAppear { selectedProduct = products.first(where: { $0.id == viewModel.selectedProductID }) } + + #if os(iOS) + if !isButtonsStyle { + toolbarView + } + #else + if storeButton.contains(.policies) { + storeButtonsAssembly?.assemble(storeButtonType: .policies) + } + #endif + } + .background(subscriptionBackground.edgesIgnoringSafeArea(.all)) + .activityIndicator(isLoading: isLoading) + case .error: + StoreUnavaliableView(productType: .subscription) + } + } + + private func productsView(products: [SubscriptionView.ViewModel]) -> some View { + subscriptionsWrapperViewStyle.makeBody( + configuration: .init( + items: products, + selectedID: selectedProduct?.id, + action: { product in + if isButtonsStyle { + self.purchase(productID: product.id) + } else { + self.selectedProduct = product + self.viewModel.presenter.selectProduct(with: product.id) + } + } + ) + ) + } + + private var loadingView: some View { + LoadingView(message: L10n.Subscription.Loading.message) + } + + #if os(iOS) + private var toolbarView: some View { + selectedProduct.map { product in + SubscriptionToolbarView( + viewModel: .init( + id: product.id, + title: product.title, + price: product.price, + description: product.description, + isActive: product.isActive + ), + action: { self.purchase(productID: product.id) } + ) + } + } + #endif + + private func purchase(productID: String) { + guard let product = viewModel.presenter.product(withID: productID) else { return } + + withAnimation { + isLoading = true + } + + Task { + do { + let transaction = try await self.viewModel.presenter.subscribe(optionsHandler: purchaseOptions) + purchaseCompletion?(product, .success(transaction)) + } catch { + if error.iap != .paymentCancelled { + self.error = error.iap + purchaseCompletion?(product, .failure(error)) + } + } + + withAnimation { + isLoading = false + } + } + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Views/LoadingView.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Views/LoadingView.swift new file mode 100644 index 000000000..3093b5c9c --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Views/LoadingView.swift @@ -0,0 +1,79 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +struct LoadingView: View { + // MARK: Types + + enum LoadingViewType { + case `default` + case backgrouned + } + + // MARK: Properties + + private let type: LoadingViewType + private let message: String + + // MARK: Initialization + + init(type: LoadingViewType = .default, message: String) { + self.type = type + self.message = message + } + + // MARK: View + + var body: some View { + switch type { + case .default: + loadingView + case .backgrouned: + loadingView + .padding() + .background( + RoundedRectangle(cornerRadius: 8.0) + .fill(Palette.systemBackground) + ) + } + } + + // MARK: Private + + private var loadingView: some View { + VStack(alignment: .center, spacing: 52) { + if #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 9.0, *) { + #if os(tvOS) + progressView + #else + progressView + .controlSize(.large) + #endif + } else if #available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) { + progressView + .scaleEffect(1.74) + } else { + #if os(macOS) + ActivityIndicatorView(isAnimating: .constant(true)) + #elseif os(watchOS) + Text("Loading...") + #else + ActivityIndicatorView(isAnimating: .constant(true), style: .large) + #endif + } + + Text(message) + .font(.subheadline) + .foregroundColor(Palette.systemGray) + } + } + + @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) + private var progressView: some View { + ProgressView() + .progressViewStyle(.circular) + } +} diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Views/SubscriptionHeaderView.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Views/SubscriptionHeaderView.swift new file mode 100644 index 000000000..e16bcf39a --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Views/SubscriptionHeaderView.swift @@ -0,0 +1,69 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionHeaderView + +@available(iOS 13.0, macOS 10.15, *) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +struct SubscriptionHeaderView: View { + // MARK: Properties + + @Environment(\.storeButtonsAssembly) private var storeButtonsAssembly + @Environment(\.storeButton) private var storeButton + @Environment(\.subscriptionMarketingContent) private var subscriptionMarketingContent + @Environment(\.subscriptionHeaderContentBackground) private var subscriptionHeaderContentBackground + @Environment(\.subscriptionViewTint) private var subscriptionViewTint + + private let topInset: CGFloat + + // MARK: Initialization + + init(topInset: CGFloat = .zero) { + self.topInset = topInset + } + + // MARK: View + + var body: some View { + VStack { + subscriptionMarketingContent.map { content in + content.frame(maxWidth: .infinity, minHeight: 250.0) + .padding(.top, topInset) + } + + policiesButton + .tintColor(subscriptionViewTint) + .padding(.bottom) + } + .background(headerBackground) + } + + // MARK: Private + + @ViewBuilder + private var headerBackground: some View { + if subscriptionMarketingContent != nil { + subscriptionHeaderContentBackground.edgesIgnoringSafeArea(.all) + } + } + + private var policiesButton: some View { + Group { + if storeButton.contains(.policies) { + storeButtonsAssembly?.assemble(storeButtonType: .policies) + } + } + } +} + +#if swift(>=5.9) && os(iOS) + #Preview { + SubscriptionHeaderView() + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Views/SubscriptionToolbarView.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Views/SubscriptionToolbarView.swift new file mode 100644 index 000000000..c3754c919 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Views/SubscriptionToolbarView.swift @@ -0,0 +1,137 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionToolbarView + +@available(iOS 13.0, *) +@available(macOS, unavailable) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +struct SubscriptionToolbarView: View { + // MARK: Properties + + @Environment(\.storeButtonsAssembly) private var storeButtonsAssembly + @Environment(\.storeButton) private var storeButton + @Environment(\.subscriptionViewTint) private var subscriptionViewTint + @Environment(\.subscriptionBackground) private var subscriptionBackground + + private let viewModel: ViewModel + private let action: () -> Void + + // MARK: Initialization + + init(viewModel: ViewModel, action: @escaping () -> Void) { + self.viewModel = viewModel + self.action = action + } + + // MARK: View + + var body: some View { + bottomToolbar { purchaseContainer } + .background( + Color.clear + #if os(iOS) || os(tvOS) + .blurEffect() + .edgesIgnoringSafeArea(.all) + #endif + ) + } + + // MARK: Private + + private var purchaseContainer: some View { + VStack(spacing: 24.0) { + subscriptionsDetailsView { + SubscriptionView( + viewModel: .init( + id: viewModel.id, + title: viewModel.title, + price: viewModel.price, + description: viewModel.description, + isActive: viewModel.isActive + ), + isSelected: .constant(false), + action: action + ) + .subscriptionControlStyle(.button) + .subscriptionButtonLabel(.multiline) + .padding(.horizontal) + } + + storeButtonView + } + } + + private func bottomToolbar(@ViewBuilder content: () -> some View) -> some View { + content() + .padding(.top) + } + + private var storeButtonView: some View { + Group { + if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) { + if storeButton.contains(.restore) { + storeButtonsAssembly?.assemble(storeButtonType: .restore) + .storeButtonViewFontWeight(.bold) + .foregroundColor(subscriptionViewTint) + } + } + } + } + + private var subscriptionsDetailsView: some View { + Text(L10n.Subscriptions.Renewable.subscriptionDescription(viewModel.price)) + .font(.footnote) + .multilineTextAlignment(.center) + .contrast(subscriptionBackground) + } + + private func subscriptionsDetailsView(@ViewBuilder content: () -> some View) -> some View { + VStack(spacing: 6.0) { + content() + subscriptionsDetailsView + .contrast(subscriptionBackground) + .font(.subheadline) + } + } +} + +// MARK: SubscriptionToolbarView.ViewModel + +@available(iOS 13.0, *) +@available(macOS, unavailable) +@available(tvOS, unavailable) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +extension SubscriptionToolbarView { + struct ViewModel { + let id: String + let title: String + let price: String + let description: String + let isActive: Bool + } +} + +#if swift(>=5.9) && os(iOS) + #Preview { + VStack { + SubscriptionToolbarView( + viewModel: .init( + id: "", + title: "Subscription", + price: "$0.99/month", + description: "Description", + isActive: true + ), + action: {} + ) + } + } +#endif diff --git a/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Views/SubscriptionView.swift b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Views/SubscriptionView.swift new file mode 100644 index 000000000..f3fa47abe --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/Views/SubscriptionView.swift @@ -0,0 +1,102 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// MARK: - SubscriptionView + +@available(watchOS, unavailable) +struct SubscriptionView: View { + // MARK: Properties + + @Environment(\.subscriptionControlStyle) private var subscriptionControlStyle + + @Binding private var isSelected: Bool + + private let viewModel: ViewModel + private let action: () -> Void + + // MARK: Initialization + + init( + viewModel: ViewModel, + isSelected: Binding, + action: @escaping () -> Void + ) { + self.viewModel = viewModel + self._isSelected = isSelected + self.action = action + } + + var body: some View { + subscriptionControlStyle.makeBody( + configuration: .init( + label: .init(titleView), + description: .init(descriptionView), + price: .init(priceView), + isSelected: isSelected, + isActive: viewModel.isActive, + action: action + ) + ) + } + + private var titleView: some View { + Text(viewModel.title) + } + + private var descriptionView: some View { + Text(viewModel.description) + } + + private var priceView: some View { + Text(viewModel.price) + } +} + +// MARK: SubscriptionView.ViewModel + +@available(watchOS, unavailable) +extension SubscriptionView { + struct ViewModel: Identifiable, Equatable, Hashable { + let id: String + let title: String + let price: String + let description: String + let isActive: Bool + } +} + +#if swift(>=5.9) && os(iOS) + #Preview { + VStack { + SubscriptionView( + viewModel: .init( + id: "", + title: "Subscription", + price: "$0.99/month", + description: "Description", + isActive: true + ), + isSelected: .constant(true), + action: {} + ) + SubscriptionView( + viewModel: .init( + id: "", + title: "Subscription", + price: "$0.99/month", + description: "Description", + isActive: false + ), + isSelected: .constant(true), + action: {} + ) + .subscriptionControlStyle(.prominentPicker) + .subscriptionViewTint(.green) + .subscriptionPickerItemBackground(Palette.systemGray5) + } + } +#endif diff --git a/Sources/FlareUI/FlareUI.docc/Articles/creating-custom-product-style.md b/Sources/FlareUI/FlareUI.docc/Articles/creating-custom-product-style.md new file mode 100644 index 000000000..cdffb5b87 --- /dev/null +++ b/Sources/FlareUI/FlareUI.docc/Articles/creating-custom-product-style.md @@ -0,0 +1,57 @@ +# Creating a Custom Product Style + +Learn how to create a custom style for a product. + +## Custom Product Style + +The `FlareUI` provides a simple way to customize the appearance of the products. For this, you need to implement an object that conforms to ``IProductStyle`` and pass it to ``SwiftUI/View/productViewStyle(_:)`` or ``SwiftUI/View/productViewStyle(_:)``. + +> note: You can use one of the predefined styles that are optimized for various platforms: ``LargeProductStyle`` and ``CompactProductStyle``. + +Based on the ``ProductStyleConfiguration/State-swift.enum`` enum, you can define different views to display for various states. + +```swift +struct CustomProductStyle: IProductStyle { + func makeBody(configuration: Configuration) -> some View { + switch configuration.state { + case .loading: + // Provide a custom loader view + case let .product(product): + // Provide a custom view that displays the product's info + case let .error(error): + // Provide a view for displaying an error to a user. + } + } +} +``` + +## Custom Subscription Control Style + +To create a custom subscription style create an object that conforms to ``ISubscriptionControlStyle``. + +```swift +struct CustomSubscriptionStyle: ISubscriptionControlStyle { + func makeBody(configuration: SubscriptionStoreControlStyleConfiguration) -> some View { + VStack { + configuration.label + .font(.title) + configuration.description + .font(.body) + configuration.price + .font(.body) + .foregroundColor(.blue) + + // If you need to ttack a selected state + // configuration.isSelected + + // If the subscription is active + // configuration.isActive + + // Triggers a purchase action + // configuration.trigger() + } + } +} +``` + +Then, we use can apply the custom style to ``SubscriptionsView/subscriptionControlStyle(_:)`` or ``SubscriptionsViewController/subscriptionControlStyle``. diff --git a/Sources/FlareUI/FlareUI.docc/Articles/displaying-products.md b/Sources/FlareUI/FlareUI.docc/Articles/displaying-products.md new file mode 100644 index 000000000..4a33c0ca7 --- /dev/null +++ b/Sources/FlareUI/FlareUI.docc/Articles/displaying-products.md @@ -0,0 +1,59 @@ +# Displaying Products + +Learn how to display a set of products to a user. + +## Overview + + +The `FlareUI` provides an easy way to display different kinds of products with a single line of code. To display a set of products to the user, just pass a collection of identifiers to the ``ProductsView``. + +```swift +ProductsView(ids: ["com.company.product_id_1", "com.company.product_id_2"]) +``` + +Once `ProductsView` fetches these products from the App Store, it will display them to the user. If the products can't be fetched for any reason, the `ProductsView` shows a message to the user that the App Store is not available. + +> important: By default, all Flare views use cached data if available; otherwise, they fetch the data. If you want to change this behavior, please read more about Flare configuration [here](link). + +## UIKit + +The `FlareUI` package provides wrappers for the UIKit views. It can be easily integrated into UIKit environments using ``ProductsViewController``. + +```swift +let productsVC = ProductsViewController(ids: ["com.company.product_id_1", "com.company.product_id_2"]) +let nav = UINavigationController(rootViewController: productsVC) +present(nav, animated: true) +``` + +The `ProductsViewController` is backed by `ProductsView`, and its behavior is the same. + +## Customization + +The appearance of the displayed products can be customized using ``SwiftUI/View/productViewStyle(_:)``. There are predefined styles for different platforms: ``LargeProductStyle``, ``CompactProductStyle``. + +You can also create your own style. For this, please, read [How to Create a Custom Product Style](). + +## Custom Buttons + +If you have restorable products, the `ProductsView` can show a restore button to the customer. For this, you can use ``SwiftUI/View/storeButton(_:types:)-4x8yd`` or ``ProductsViewController/storeButton(_:types:)``. + +```swift +// SwiftUI + +ProductsView(ids: ["com.company.product_id_1", "com.company.product_id_2"]) + .storeButton(.visible, types: .restore) +``` + +```swift +// UIKit + +let productsVC = ProductsViewController(ids: ["com.company.product_id_1", "com.company.product_id_2"]) +productsVC.storeButton(.visible, types: [.restore]) +``` + +## Topics + +### Articles + +- +- diff --git a/Sources/FlareUI/FlareUI.docc/Articles/displaying-subscriptions.md b/Sources/FlareUI/FlareUI.docc/Articles/displaying-subscriptions.md new file mode 100644 index 000000000..396120ccc --- /dev/null +++ b/Sources/FlareUI/FlareUI.docc/Articles/displaying-subscriptions.md @@ -0,0 +1,252 @@ +# Displaying Subscriptions + +Learn how to display a set of subscriptions to a user. + +## Overview + +The `FlareUI` provides a way to display subscriptions to a customer. To display a set of products to the user, simply pass a collection of identifiers to the ``SubscriptionsView`` or ``SubscriptionsViewController``. + +```swift +SubscriptionsView(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) +``` + +![SubscriprionsView](subscription_view.png) + +Once `SubscriptionsView` fetches these subscriptions from the App Store, it will display them to the user. If the subscriptions can't be fetched for any reason, the `SubscriptionsView` shows a message to the user that the App Store is not available. + +> important: By default, all Flare views use cached data if available; otherwise, they fetch the data. If you want to change this behavior, please read more about Flare configuration [here](https://space-code.github.io/flare/flare/documentation/flare/caching). + +## UIKit + +The `FlareUI` package provides wrappers for the UIKit views. It can be easily integrated into UIKit environments using ``SubscriptionsViewController``. + +```swift +let subscriptionsVC = SubscriptionsViewController(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) +let nav = UINavigationController(rootViewController: subscriptionsVC) +present(nav, animated: true) +``` + +The `SubscriptionsViewController` is backed by `SubscriptionsView`, and its behavior is the same. + +## Customization + +``SubscriptionsViewController`` and ``SubscriptionsView`` provide a set of properties and methods to change the style, handle transactions, and more. + +### Handling a Completion Result + +You can handle a completion of a purchase using ``SubscriptionsViewController/onInAppPurchaseCompletion`` and ``SubscriptionsView/onInAppPurchaseCompletion(completion:)``. + +> note: You can read more about [How to Handle Transactions](). + +```swift +// SwiftUI + +SubscriptionsView(ids: ["com.company.subscription_id_1"]) + .onInAppPurchaseCompletion { result in + switch result { + case let .success(transaction): + // Handle the transaction + case let .failure(error): + // Handle the error + } + } +``` + +```swift +// UIKit + +let subscriptionsVC = SubscriptionsViewController(ids: ["com.company.subscription_id_1"]) +subscriptionsVC.onInAppPurchaseCompletion = { result in + switch result { + case let .success(transaction): + // Handle the transaction + case let .failure(error): + // Handle the error + } +} +``` + +### Passing Custom Parameters + +Starting from iOS 15.0, macOS 12.0, and tvOS 15.0, you can pass extracted parameters to the purchase for a specific product. Use ``SwiftUI/View/inAppPurchaseOptions(_:)`` or ``SubscriptionsViewController/inAppPurchaseOptions(_:)``. + +> note: You can read more about [Purchase Options](https://developer.apple.com/documentation/storekit/product/purchaseoption). + +```swift +// SwiftUI + +SubscriptionsView(ids: ["com.company.subscription_id_1"]) + .inAppPurchaseOptions { product in + return .init(options: [.appAccountToken(UUID())]) + } +``` + +```swift +// UIKit + +let subscriptionsVC = SubscriptionsViewController(ids: ["com.company.subscription_id_1"]) +subscriptionsVC.inAppPurchaseOptions { product in + return .init(options: [.appAccountToken(UUID())]) +} +``` + +### Subscription Control Style + +You can change the default style of subscription items using ``SwiftUI/View/subscriptionControlStyle(_:)`` or ``SubscriptionsViewController/subscriptionControlStyle``. + +![SubscriprionsView](button_styles.png) + +> note: If you want to create a custom style for subscription, you can read about this more [here](). + +### Subscription Background Color + +By default, ``SubscriptionsView`` and ``SubscriptionsViewController`` provide a default background color. In case you want to change its background color use ``SubscriptionsView/subscriptionBackground(_:)`` or ``SubscriptionsViewController/subscriptionBackgroundColor``. + +```swift +// SwiftUI + +SubscriptionsView(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) + .subscriptionBackground(Color.blue) +``` + +```swift +// UIKit + +let subscriptionsVC = SubscriptionsViewController(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) +subscriptionsVC.subscriptionBackgroundColor = .blue +``` + +> note: To change a header background use ``SwiftUI/View/subscriptionHeaderContentBackground(_:)`` or ``SubscriptionsViewController/subscriptionHeaderContentBackground``. + +### Tint Color + +To change a tint color of a view, use ``SwiftUI/View/tintColor(_:)`` or ``SubscriptionsViewController/subscriptionViewTintColor``. + +```swift +// SwiftUI + +SubscriptionsView(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) + .tintColor(Color.blue) +``` + +```swift +// UIKit + +let subscriptionsVC = SubscriptionsViewController(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) +subscriptionsVC.subscriptionViewTintColor = .blue +``` + +### Button Label Style + +You can change the style of a button's label using ``SwiftUI/View/subscriptionButtonLabel(_:)`` or ``SubscriptionsViewController/subscriptionButtonLabelStyle``. + +### Marketing Content + +To provide a custom header for a subscription view, use the ``SubscriptionsViewController/subscriptionMarketingContnentView`` or ``SubscriptionsView/subscriptionMarketingContent(view:)`` property. + +```swift +// SwiftUI + +var headerView: some View { ... } + +SubscriptionsView(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) + .subscriptionMarketingContent(headerView) +``` + +```swift +// UIKit + +let headerView: UIView = ... + +let subscriptionsVC = SubscriptionsViewController(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) +subscriptionsVC.subscriptionHeaderView = headerView +``` + +## Custom Buttons + +### Restore Button + +If you want to get a way to restore subscription to a user, the `SubscriptionsView` can show a restore button to the customer. For this, you can use ``SwiftUI/View/storeButton(_:types:)-4x8yd`` or ``SubscriptionsViewController/storeButton(_:types:)``. + +```swift +// SwiftUI + +SubscriptionsView(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) + .storeButton(.visible, types: .restore) +``` + +```swift +// UIKit + +let subscriptionsVC = SubscriptionsViewController(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) +subscriptionsVC.storeButton(.visible, types: [.restore]) +``` + +### Terms of Service and Privacy Policy Buttons + +Also, you can provide buttons for the terms of service and privacy policy. There are two ways to do it: you can provide a URL to a webpage with content or pass a custom view that displays this information. + +To provide URLs to these pages, use ``SwiftUI/View/subscriptionTermsOfServiceURL(_:)`` and ``SwiftUI/View/subscriptionPrivacyPolicyURL(_:)``. + +```swift +// SwiftUI + +SubscriptionsView(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) + .storeButton(.visible, types: .policies) + .subscriptionTermsOfServiceURL(URL(string: "An URL to terms of service page")!) + .subscriptionPrivacyPolicyURL(URL(string: "An URL to privacy policy page)!) +``` + +To provide custom views, use ``SwiftUI/View/subscriptionTermsOfServiceDestination(content:)`` and ``SwiftUI/View/subscriptionPrivacyPolicyDestination(content:)``. + +```swift +// SwiftUI + +var termsOfServiceView: some View { ... } +var privacyPolicyView: some View { ... } + +SubscriptionsView(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) + .storeButton(.visible, types: .policies) + .subscriptionTermsOfServiceURL(termsOfServiceView) + .subscriptionPrivacyPolicyDestination(privacyPolicyView) +``` + +This functionality is also supported by the UIKit wrapper. You can use ``SubscriptionsViewController/subscriptionTermsOfServiceURL`` or ``SubscriptionsViewController/subscriptionPrivacyPolicyURL`` to pass URLs to webpages, or ``SubscriptionsViewController/subscriptionTermsOfServiceView`` or ``SubscriptionsViewController/subscriptionPrivacyPolicyView`` to provide custom views. + +```swift +// UIKit + +let subscriptionsVC = SubscriptionsViewController(ids: ["com.company.subscription_id_1", "com.company.subscription_id_2"]) + +// Custom views + +subscriptionsVC.subscriptionPrivacyPolicyView = ... // set a custom privacy policy view +subscriptionsVC.subscriptionTermsOfServiceView = ... // set a custom terms of service view + +// Set URLs + +subscriptionsVC.subscriptionPrivacyPolicyURL = ... // set a custom privacy policy url +subscriptionsVC.subscriptionTermsOfServiceURL = ... // set a custom terms of service url + +let nav = UINavigationController(rootViewController: subscriptionsVC) +present(nav, animated: true) +``` + +## Active Subscription + +Starting from iOS 15.0, macOS 12.0, and tvOS 15.0, Flare can detect active subscriptions under the hood. If you need to support lower OS versions or you want to implement custom logic, you can create an object that conforms to ``ISubscriptionStatusVerifier`` and pass it to the configuration method of FlareUI. + +```swift +struct SubscriptionVerifier: ISubscriptionStatusVerifier { + func validate(_ storeProduct: StoreProduct) async throws -> Bool { + // Write your logic here + } +} +``` + +To configure FlareUI with a custom subscription verifier, pass the verifier using the configuration. + +```swift +let subscriptionVerifier = SubscriptionVerifier() +FlareUI.configure(with: UIConfiguration(subscriptionVerifier: subscriptionVerifier)) +``` diff --git a/Sources/FlareUI/FlareUI.docc/Articles/handling-transactions.md b/Sources/FlareUI/FlareUI.docc/Articles/handling-transactions.md new file mode 100644 index 000000000..eb69342d1 --- /dev/null +++ b/Sources/FlareUI/FlareUI.docc/Articles/handling-transactions.md @@ -0,0 +1,41 @@ +# Handling Transactions + +Learn how to handle transactions. + +## Overview + +Views provide a handy way to handle completed or failed transactions using ``SwiftUI/View/onInAppPurchaseCompletion(completion:)`` or ``ProductsViewController/onInAppPurchaseCompletion``. + +```swift +// SwiftUI + +ProductsView(ids: ["com.company.product_id_1", "com.company.product_id_2"]) + .onInAppPurchaseCompletion { result in + switch result { + case let .success(transaction): + // Handle the transaction + + // IMPORTANT: Finish the transaction + case let .failure(error): + // Handle the error + } + } +``` + +> important: Once you've handled a transaction, don't forget to finish it using [Finish Method](https://space-code.github.io/flare/flare/documentation/flare/iflare/finish(transaction:completion:)). Read more about [Finishing Transactions](https://space-code.github.io/flare/flare/documentation/flare/perform-purchase). + +```swift +// UIKit + +let subscriptionsVC = SubscriptionsViewController(ids: ["com.company.subscription_id_1"]) +subscriptionsVC.onInAppPurchaseCompletion = { result in + switch result { + case let .success(transaction): + // Handle the transaction + + // IMPORTANT: Finish the transaction + case let .failure(error): + // Handle the error + } +} +``` diff --git a/Sources/FlareUI/FlareUI.docc/FlareUI.md b/Sources/FlareUI/FlareUI.docc/FlareUI.md new file mode 100644 index 000000000..0ff3f1025 --- /dev/null +++ b/Sources/FlareUI/FlareUI.docc/FlareUI.md @@ -0,0 +1,35 @@ +# ``FlareUI`` + +Display a customizable in-app purchase store using StoreKit views for UIKit and SwiftUI. + +## Overview + +FlareUI provides UI to help you build a store for your in-app purchases, and provide a way for customers to complete the purchase. The views support localization, so your customers see the product names, descriptions, and prices appropriate to their App Store storefront. + +## SwiftUI + +The easiest way to display in-app purchases is by using ``SubscriptionsView`` and ``ProductsView``. + +```swift +SubscriptionsView(ids: [com.company.subscription_id]) +``` + +## UIKit + +To present the in-app purchases from UIKit, use ``SubscriptionsViewController`` or ``ProductsViewController``. + +```swift +let subscriptionsVC = SubscriptionsViewController(ids: [com.company.subscription_id]) +let nav = UINavigationController(rootViewController: subscriptionsVC) +present(nav, animated: true) +``` + +## Minimum Requirements + +| FlareUI | Date | Swift | Xcode | Platforms | +|---------|------------|-------|---------|-------------------------------------------------------------| +| 3.0 | unreleased | 5.7 | 14.1 | iOS 13.0, macOS 10.15, tvOS 13.0 + +## License + +flare is available under the MIT license. See the LICENSE file for more info. diff --git a/Sources/FlareUI/FlareUI.docc/Resources/Images/button_styles.png b/Sources/FlareUI/FlareUI.docc/Resources/Images/button_styles.png new file mode 100644 index 000000000..0063ed03c Binary files /dev/null and b/Sources/FlareUI/FlareUI.docc/Resources/Images/button_styles.png differ diff --git a/Sources/FlareUI/FlareUI.docc/Resources/Images/content_buttons.png b/Sources/FlareUI/FlareUI.docc/Resources/Images/content_buttons.png new file mode 100644 index 000000000..e61534bc0 Binary files /dev/null and b/Sources/FlareUI/FlareUI.docc/Resources/Images/content_buttons.png differ diff --git a/Sources/FlareUI/FlareUI.docc/Resources/Images/subscription_view.png b/Sources/FlareUI/FlareUI.docc/Resources/Images/subscription_view.png new file mode 100644 index 000000000..aa9de8409 Binary files /dev/null and b/Sources/FlareUI/FlareUI.docc/Resources/Images/subscription_view.png differ diff --git a/Sources/FlareUI/FlareUI.docc/Resources/Images/subscription_view@2x.png b/Sources/FlareUI/FlareUI.docc/Resources/Images/subscription_view@2x.png new file mode 100644 index 000000000..33c6ecba1 Binary files /dev/null and b/Sources/FlareUI/FlareUI.docc/Resources/Images/subscription_view@2x.png differ diff --git a/Sources/FlareUI/Makefile b/Sources/FlareUI/Makefile new file mode 100644 index 000000000..1a5286c90 --- /dev/null +++ b/Sources/FlareUI/Makefile @@ -0,0 +1,2 @@ +swiftgen: + swiftgen \ No newline at end of file diff --git a/Sources/FlareUI/Resources/Assets/Assets.xcassets/Colors/Contents.json b/Sources/FlareUI/Resources/Assets/Assets.xcassets/Colors/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Assets.xcassets/Colors/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Sources/FlareUI/Resources/Assets/Assets.xcassets/Colors/dynamic_background.colorset/Contents.json b/Sources/FlareUI/Resources/Assets/Assets.xcassets/Colors/dynamic_background.colorset/Contents.json new file mode 100644 index 000000000..5f3de9e5c --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Assets.xcassets/Colors/dynamic_background.colorset/Contents.json @@ -0,0 +1,68 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "255", + "green" : "255", + "red" : "255" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0", + "green" : "0", + "red" : "0" + } + }, + "idiom" : "universal" + }, + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "255", + "green" : "255", + "red" : "255" + } + }, + "idiom" : "tv" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "38", + "green" : "38", + "red" : "38" + } + }, + "idiom" : "tv" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/FlareUI/Resources/Assets/Assets.xcassets/Colors/gray.colorset/Contents.json b/Sources/FlareUI/Resources/Assets/Assets.xcassets/Colors/gray.colorset/Contents.json new file mode 100644 index 000000000..65b9a7b68 --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Assets.xcassets/Colors/gray.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "247", + "green" : "242", + "red" : "242" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "30", + "green" : "28", + "red" : "28" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/FlareUI/Resources/Assets/Assets.xcassets/Colors/system_background.colorset/Contents.json b/Sources/FlareUI/Resources/Assets/Assets.xcassets/Colors/system_background.colorset/Contents.json new file mode 100644 index 000000000..9c2bf753b --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Assets.xcassets/Colors/system_background.colorset/Contents.json @@ -0,0 +1,68 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "255", + "green" : "255", + "red" : "255" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0", + "green" : "0", + "red" : "0" + } + }, + "idiom" : "universal" + }, + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "178", + "green" : "178", + "red" : "178" + } + }, + "idiom" : "tv" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "28", + "green" : "28", + "red" : "28" + } + }, + "idiom" : "tv" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/FlareUI/Resources/Assets/Assets.xcassets/Contents.json b/Sources/FlareUI/Resources/Assets/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/FlareUI/Resources/Assets/Media.xcassets/Contents.json b/Sources/FlareUI/Resources/Assets/Media.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/Contents.json b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/checkmark.imageset/Contents.json b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/checkmark.imageset/Contents.json new file mode 100644 index 000000000..cd4b3af1b --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/checkmark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "checkmark.circle.fill.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/checkmark.imageset/checkmark.circle.fill.svg b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/checkmark.imageset/checkmark.circle.fill.svg new file mode 100644 index 000000000..2383ffecb --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/checkmark.imageset/checkmark.circle.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/circle.imageset/Contents.json b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/circle.imageset/Contents.json new file mode 100644 index 000000000..29d1b4b46 --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/circle.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "circle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/circle.imageset/circle.svg b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/circle.imageset/circle.svg new file mode 100644 index 000000000..9de1a0c3a --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/circle.imageset/circle.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/star.imageset/Contents.json b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/star.imageset/Contents.json new file mode 100644 index 000000000..fb87640df --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/star.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "star.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/star.imageset/star.svg b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/star.imageset/star.svg new file mode 100644 index 000000000..44f14f52f --- /dev/null +++ b/Sources/FlareUI/Resources/Assets/Media.xcassets/Media/star.imageset/star.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/Sources/FlareUI/Resources/Localization/en.lproj/Localizable.strings b/Sources/FlareUI/Resources/Localization/en.lproj/Localizable.strings new file mode 100644 index 000000000..94b1a967d --- /dev/null +++ b/Sources/FlareUI/Resources/Localization/en.lproj/Localizable.strings @@ -0,0 +1,20 @@ +"store_unavailable.title" = "Store Unavailable"; +"store_unavailable.product.message" = "No in-app purchases are available in the current storefront."; +"store_unavailable.subscription.message" = "The subscription is unavailable in the current storefront."; +"store_button.restore_purchases" = "Restore Missing Purchases"; +"error.default.title" = "Error Occurred"; +"product.subscription.price" = "%@/%@"; +"product.price_description" = "Every %@"; +"subscriptions.renewable.subscription_description" = "Plan auto-renews for %@ until cancelled."; +"subscriptions.renewable.subscription_description_separated" = "Plan auto-renews for %@\nuntil cancelled."; +"subscription.loading.message" = "Loading Subscriptions..."; +"common.terms_of_service" = "Terms of Service"; +"common.privacy_policy" = "Privacy Policy"; +"common.words.and" = "and"; +"policies.unavailable.terms_of_service.title" = "Terms of Service Unavailable"; +"policies.unavailable.privacy_policy.title" = "Privacy Policy Unavailable"; +"policies.unavailable.terms_of_service.message" = "Something went wrong. Try again."; +"policies.unavailable.privacy_policy.message" = "Something went wrong. Try again."; +"common.subscription.status.your_plan" = "Your plan"; +"common.subscription.status.your_current_plan" = "Your current plan"; +"common.subscription.action.subscribe" = "Subscribe"; diff --git a/Sources/FlareUI/Resources/Localization/ru.lproj/Localizable.strings b/Sources/FlareUI/Resources/Localization/ru.lproj/Localizable.strings new file mode 100644 index 000000000..f140e921e --- /dev/null +++ b/Sources/FlareUI/Resources/Localization/ru.lproj/Localizable.strings @@ -0,0 +1,20 @@ +"store_unavailable.title" = "Магазин недоступен"; +"store_unavailable.product.message" = "В текущем магазине недоступны покупки в приложении."; +"store_unavailable.subscription.message" = "Подписка недоступна в текущем магазине."; +"store_button.restore_purchases" = "Восстановить покупки"; +"error.default.title" = "Произошла ошибка"; +"product.subscription.price" = "%@/%@"; +"product.price_description" = "Каждый %@"; +"subscriptions.renewable.subscription_description" = "План автоматически продлевается за %@ до отмены."; +"subscriptions.renewable.subscription_description_separated" = "План автоматически продлевается за %@\nдо отмены."; +"subscription.loading.message" = "Загрузка подписок..."; +"common.terms_of_service" = "Условия использования"; +"common.privacy_policy" = "Политика конфиденциальности"; +"common.words.and" = "и"; +"policies.unavailable.terms_of_service.title" = "Условия предоставления услуг недоступны"; +"policies.unavailable.privacy_policy.title" = "Политика конфиденциальности недоступна"; +"policies.unavailable.terms_of_service.message" = "Что-то пошло не так. Попробуйте снова."; +"policies.unavailable.privacy_policy.message" = "Что-то пошло не так. Попробуйте снова."; +"common.subscription.status.your_plan" = "Ваш план"; +"common.subscription.status.your_current_plan" = "Ваш текущий план"; +"common.subscription.action.subscribe" = "Подписаться"; diff --git a/Sources/FlareUI/swiftgen.yml b/Sources/FlareUI/swiftgen.yml new file mode 100644 index 000000000..0c0a394cd --- /dev/null +++ b/Sources/FlareUI/swiftgen.yml @@ -0,0 +1,24 @@ +input_dir: Resources +output_dir: Classes/Generated +xcassets: + - inputs: Assets/Assets.xcassets + outputs: + templateName: swift5 + output: Colors.swift + params: + publicAccess: false + - inputs: Assets/Media.xcassets + outputs: + templateName: swift5 + output: Media.swift + params: + publicAccess: false + enumName: Media +strings: + inputs: + - Localization/en.lproj/Localizable.strings + outputs: + templateName: structured-swift5 + output: Strings.swift + params: + publicAccess: false \ No newline at end of file diff --git a/Sources/FlareUIMock/Mocks/FlareMock.swift b/Sources/FlareUIMock/Mocks/FlareMock.swift new file mode 100644 index 000000000..364f3cfa6 --- /dev/null +++ b/Sources/FlareUIMock/Mocks/FlareMock.swift @@ -0,0 +1,302 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import FlareMock +import Foundation +import Log +import StoreKit + +public final class FlareMock: IFlare { + public init() {} + + public var invokedLogLevelSetter = false + public var invokedLogLevelSetterCount = 0 + public var invokedLogLevel: Log.LogLevel? + public var invokedLogLevelList = [Log.LogLevel]() + public var invokedLogLevelGetter = false + public var invokedLogLevelGetterCount = 0 + public var stubbedLogLevel: Log.LogLevel! + + public var logLevel: Log.LogLevel { + set { + invokedLogLevelSetter = true + invokedLogLevelSetterCount += 1 + invokedLogLevel = newValue + invokedLogLevelList.append(newValue) + } + get { + invokedLogLevelGetter = true + invokedLogLevelGetterCount += 1 + return stubbedLogLevel + } + } + + public var invokedFetchProductIDs = false + public var invokedFetchProductIDsCount = 0 + public var invokedFetchProductIDsParameters: (productIDs: Any, completion: Closure>)? + public var invokedFetchProductIDsParametersList = [(productIDs: Any, completion: Closure>)]() + + public func fetch(productIDs: some Collection, completion: @escaping Closure>) { + invokedFetchProductIDs = true + invokedFetchProductIDsCount += 1 + invokedFetchProductIDsParameters = (productIDs, completion) + invokedFetchProductIDsParametersList.append((productIDs, completion)) + } + + public var invokedFetch = false + public var invokedFetchCount = 0 + public var invokedFetchParameters: (productIDs: Any, Void)? + public var invokedFetchParametersList = [(productIDs: Any, Void)]() + public var stubbedFetchError: Error? + public var stubbedInvokedFetch: [StoreProduct] = [] + + public func fetch(productIDs: some Collection) async throws -> [StoreProduct] { + invokedFetch = true + invokedFetchCount += 1 + invokedFetchParameters = (productIDs, ()) + invokedFetchParametersList.append((productIDs, ())) + if let stubbedFetchError = stubbedFetchError { + throw stubbedFetchError + } + return stubbedInvokedFetch + } + + public var invokedPurchaseProductPromotionalOffer = false + public var invokedPurchaseProductPromotionalOfferCount = 0 + public var invokedPurchaseProductPromotionalOfferParameters: ( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: Closure> + )? + public var invokedPurchaseProductPromotionalOfferParametersList = [( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: Closure> + )]() + + public func purchase( + product: StoreProduct, + promotionalOffer: PromotionalOffer?, + completion: @escaping Closure> + ) { + invokedPurchaseProductPromotionalOffer = true + invokedPurchaseProductPromotionalOfferCount += 1 + invokedPurchaseProductPromotionalOfferParameters = (product, promotionalOffer, completion) + invokedPurchaseProductPromotionalOfferParametersList.append((product, promotionalOffer, completion)) + } + + public var invokedPurchase = false + public var invokedPurchaseCount = 0 + public var invokedPurchaseParameters: (product: StoreProduct, promotionalOffer: PromotionalOffer?)? + public var invokedPurchaseParametersList = [(product: StoreProduct, promotionalOffer: PromotionalOffer?)]() + public var stubbedPurchaseError: Error? + public var stubbedPurchase: StoreTransaction! + + public func purchase(product: StoreProduct, promotionalOffer: PromotionalOffer?) async throws -> StoreTransaction { + invokedPurchase = true + invokedPurchaseCount += 1 + invokedPurchaseParameters = (product, promotionalOffer) + invokedPurchaseParametersList.append((product, promotionalOffer)) + if let stubbedPurchaseError = stubbedPurchaseError { + throw stubbedPurchaseError + } + return stubbedPurchase + } + + public var invokedPurchaseProductOptionsPromotionalOffer = false + public var invokedPurchaseProductOptionsPromotionalOfferCount = 0 + public var invokedPurchaseProductOptionsPromotionalOfferParameters: ( + product: StoreProduct, + options: Any, + promotionalOffer: PromotionalOffer?, + completion: SendableClosure> + )? + public var invokedPurchaseProductOptionsPromotionalOfferParametersList = [( + product: StoreProduct, + options: Any, + promotionalOffer: PromotionalOffer?, + completion: SendableClosure> + )]() + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer?, + completion: @escaping SendableClosure> + ) { + invokedPurchaseProductOptionsPromotionalOffer = true + invokedPurchaseProductOptionsPromotionalOfferCount += 1 + invokedPurchaseProductOptionsPromotionalOfferParameters = (product, options, promotionalOffer, completion) + invokedPurchaseProductOptionsPromotionalOfferParametersList.append((product, options, promotionalOffer, completion)) + } + + public var invokedPurchaseProductOptions = false + public var invokedPurchaseProductOptionsCount = 0 + public var invokedPurchaseProductOptionsParameters: (product: StoreProduct, options: Any, promotionalOffer: PromotionalOffer?)? + public var invokedPurchaseProductOptionsParametersList = [(product: StoreProduct, options: Any, promotionalOffer: PromotionalOffer?)]() + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public func purchase( + product: StoreProduct, + options: Set, + promotionalOffer: PromotionalOffer? + ) { + invokedPurchaseProductOptions = true + invokedPurchaseProductOptionsCount += 1 + invokedPurchaseProductOptionsParameters = (product, options, promotionalOffer) + invokedPurchaseProductOptionsParametersList.append((product, options, promotionalOffer)) + } + + public var invokedReceiptCompletion = false + public var invokedReceiptCompletionCount = 0 + public var invokedReceiptCompletionParameters: (completion: Closure>, Void)? + public var invokedReceiptCompletionParametersList = [(completion: Closure>, Void)]() + + public func receipt(completion: @escaping Closure>) { + invokedReceiptCompletion = true + invokedReceiptCompletionCount += 1 + invokedReceiptCompletionParameters = (completion, ()) + invokedReceiptCompletionParametersList.append((completion, ())) + } + + public var invokedReceipt = false + public var invokedReceiptCount = 0 + + public func receipt() { + invokedReceipt = true + invokedReceiptCount += 1 + } + + public var invokedFinishTransaction = false + public var invokedFinishTransactionCount = 0 + public var invokedFinishTransactionParameters: (transaction: StoreTransaction, Void)? + public var invokedFinishTransactionParametersList = [(transaction: StoreTransaction, Void)]() + public var shouldInvokeFinishTransactionCompletion = false + + public func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) { + invokedFinishTransaction = true + invokedFinishTransactionCount += 1 + invokedFinishTransactionParameters = (transaction, ()) + invokedFinishTransactionParametersList.append((transaction, ())) + if shouldInvokeFinishTransactionCompletion { + completion?() + } + } + + public var invokedFinish = false + public var invokedFinishCount = 0 + public var invokedFinishParameters: (transaction: StoreTransaction, Void)? + public var invokedFinishParametersList = [(transaction: StoreTransaction, Void)]() + + public func finish(transaction: StoreTransaction) { + invokedFinish = true + invokedFinishCount += 1 + invokedFinishParameters = (transaction, ()) + invokedFinishParametersList.append((transaction, ())) + } + + public var invokedAddTransactionObserver = false + public var invokedAddTransactionObserverCount = 0 + public var invokedAddTransactionObserverParameters: (fallbackHandler: Closure>?, Void)? + public var invokedAddTransactionObserverParametersList = [(fallbackHandler: Closure>?, Void)]() + + public func addTransactionObserver(fallbackHandler: Closure>?) { + invokedAddTransactionObserver = true + invokedAddTransactionObserverCount += 1 + invokedAddTransactionObserverParameters = (fallbackHandler, ()) + invokedAddTransactionObserverParametersList.append((fallbackHandler, ())) + } + + public var invokedRemoveTransactionObserver = false + public var invokedRemoveTransactionObserverCount = 0 + + public func removeTransactionObserver() { + invokedRemoveTransactionObserver = true + invokedRemoveTransactionObserverCount += 1 + } + + public var invokedCheckEligibility = false + public var invokedCheckEligibilityCount = 0 + public var invokedCheckEligibilityParameters: (productIDs: Set, Void)? + public var invokedCheckEligibilityParametersList = [(productIDs: Set, Void)]() + + public func checkEligibility(productIDs: Set) { + invokedCheckEligibility = true + invokedCheckEligibilityCount += 1 + invokedCheckEligibilityParameters = (productIDs, ()) + invokedCheckEligibilityParametersList.append((productIDs, ())) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public func purchase( + product _: StoreProduct, + options _: Set, + promotionalOffer _: PromotionalOffer? + ) async throws -> StoreTransaction { + StoreTransaction(paymentTransaction: PaymentTransaction(PaymentTransactionMock())) + } + + public func receipt() async throws -> String { + "" + } + + public func checkEligibility(productIDs _: Set) async throws -> [String: SubscriptionEligibility] { + [:] + } + + public var invokedRestore = false + public var invokedRestoreCount = 0 + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + public func restore() async throws { + invokedRestore = true + invokedRestoreCount += 1 + } + + #if os(iOS) || VISION_OS + public var invokedBeginRefundRequest = false + public var invokedBeginRefundRequestCount = 0 + public var invokedBeginRefundRequestParameters: (productID: String, Void)? + public var invokedBeginRefundRequestParametersList = [(productID: String, Void)]() + public var stubbedBeginRefundRequest: RefundRequestStatus! + + @available(iOS 15.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public func beginRefundRequest(productID: String) async throws -> RefundRequestStatus { + invokedBeginRefundRequest = true + invokedBeginRefundRequestCount += 1 + invokedBeginRefundRequestParameters = (productID, ()) + invokedBeginRefundRequestParametersList.append((productID, ())) + return stubbedBeginRefundRequest + } + + public var invokedPresentCodeRedemptionSheet = false + public var invokedPresentCodeRedemptionSheetCount = 0 + + @available(iOS 14.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public func presentCodeRedemptionSheet() { + invokedPresentCodeRedemptionSheet = true + invokedPresentCodeRedemptionSheetCount += 1 + } + + public var invokedPresentOfferCodeRedeemSheet = false + public var invokedPresentOfferCodeRedeemSheetCount = 0 + + @available(iOS 16.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public func presentOfferCodeRedeemSheet() { + invokedPresentOfferCodeRedeemSheet = true + invokedPresentOfferCodeRedeemSheetCount += 1 + } + #endif +} diff --git a/Sources/FlareUIMock/Mocks/ProductPresenterMock.swift b/Sources/FlareUIMock/Mocks/ProductPresenterMock.swift new file mode 100644 index 000000000..7ac95c8f4 --- /dev/null +++ b/Sources/FlareUIMock/Mocks/ProductPresenterMock.swift @@ -0,0 +1,28 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +@testable import FlareUI +import Foundation + +public final class ProductPresenterMock: IProductPresenter { + public init() {} + + public func viewDidLoad() {} + + public var invokedPurchase = false + public var invokedPurchaseCount = 0 + public var invokedPurchaseParameters: (options: PurchaseOptions?, Void)? + public var invokedPurchaseParametersList = [(options: PurchaseOptions?, Void)]() + public var stubbedPurchase: StoreTransaction = .fake() + + public func purchase(options: PurchaseOptions?) async throws -> StoreTransaction { + invokedPurchase = true + invokedPurchaseCount += 1 + invokedPurchaseParameters = (options, ()) + invokedPurchaseParametersList.append((options, ())) + return stubbedPurchase + } +} diff --git a/Sources/FlareUIMock/Mocks/ProductViewAssemblyMock.swift b/Sources/FlareUIMock/Mocks/ProductViewAssemblyMock.swift new file mode 100644 index 000000000..91c1364d1 --- /dev/null +++ b/Sources/FlareUIMock/Mocks/ProductViewAssemblyMock.swift @@ -0,0 +1,40 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +@testable import FlareUI +import Foundation + +public final class ProductViewAssemblyMock: IProductViewAssembly { + public init() {} + + public var invokedAssembleId = false + public var invokedAssembleIdCount = 0 + public var invokedAssembleIdParameters: (id: String, Void)? + public var invokedAssembleIdParametersList = [(id: String, Void)]() + public var stubbedAssembleIdResult: ViewWrapper! + + public func assemble(id: String) -> ViewWrapper { + invokedAssembleId = true + invokedAssembleIdCount += 1 + invokedAssembleIdParameters = (id, ()) + invokedAssembleIdParametersList.append((id, ())) + return stubbedAssembleIdResult + } + + public var invokedAssembleStoreProduct = false + public var invokedAssembleStoreProductCount = 0 + public var invokedAssembleStoreProductParameters: (storeProduct: StoreProduct, Void)? + public var invokedAssembleStoreProductParametersList = [(storeProduct: StoreProduct, Void)]() + public var stubbedAssembleStoreProductResult: ViewWrapper! + + public func assemble(storeProduct: StoreProduct) -> ViewWrapper { + invokedAssembleStoreProduct = true + invokedAssembleStoreProductCount += 1 + invokedAssembleStoreProductParameters = (storeProduct, ()) + invokedAssembleStoreProductParametersList.append((storeProduct, ())) + return stubbedAssembleStoreProductResult + } +} diff --git a/Sources/FlareUIMock/Mocks/ProductsPresenterMock.swift b/Sources/FlareUIMock/Mocks/ProductsPresenterMock.swift new file mode 100644 index 000000000..a06a6aef5 --- /dev/null +++ b/Sources/FlareUIMock/Mocks/ProductsPresenterMock.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import FlareUI +import Foundation + +public final class ProductsPresenterMock: IProductsPresenter { + public init() {} + + public var invokedViewDidLoad = false + public var invokedViewDidLoadCount = 0 + + public func viewDidLoad() { + invokedViewDidLoad = true + invokedViewDidLoadCount += 1 + } +} diff --git a/Sources/FlareUIMock/Mocks/StoreButtonAssemblyMock.swift b/Sources/FlareUIMock/Mocks/StoreButtonAssemblyMock.swift new file mode 100644 index 000000000..c1f5c4187 --- /dev/null +++ b/Sources/FlareUIMock/Mocks/StoreButtonAssemblyMock.swift @@ -0,0 +1,24 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import FlareUI + +public final class StoreButtonAssemblyMock: IStoreButtonAssembly { + public init() {} + + public var invokedAssemble = false + public var invokedAssembleCount = 0 + public var invokedAssembleParameters: (storeButtonType: StoreButton, Void)? + public var invokedAssembleParametersList = [(storeButtonType: StoreButton, Void)]() + public var stubbedAssembleResult: ViewWrapper! + + public func assemble(storeButtonType: StoreButton) -> ViewWrapper { + invokedAssemble = true + invokedAssembleCount += 1 + invokedAssembleParameters = (storeButtonType, ()) + invokedAssembleParametersList.append((storeButtonType, ())) + return stubbedAssembleResult + } +} diff --git a/Sources/FlareUIMock/Mocks/StoreButtonsAssemblyMock.swift b/Sources/FlareUIMock/Mocks/StoreButtonsAssemblyMock.swift new file mode 100644 index 000000000..23133f15d --- /dev/null +++ b/Sources/FlareUIMock/Mocks/StoreButtonsAssemblyMock.swift @@ -0,0 +1,25 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import FlareUI +import SwiftUI + +public final class StoreButtonsAssemblyMock: IStoreButtonsAssembly { + public init() {} + + public var invokedAssemble = false + public var invokedAssembleCount = 0 + public var invokedAssembleParameters: (storeButtonType: StoreButtonType, Void)? + public var invokedAssembleParametersList = [(storeButtonType: StoreButtonType, Void)]() + public var stubbedAssembleResult: AnyView! + + public func assemble(storeButtonType: StoreButtonType) -> AnyView { + invokedAssemble = true + invokedAssembleCount += 1 + invokedAssembleParameters = (storeButtonType, ()) + invokedAssembleParametersList.append((storeButtonType, ())) + return stubbedAssembleResult + } +} diff --git a/Sources/FlareUIMock/Mocks/SubscriptionsPresenterMock.swift b/Sources/FlareUIMock/Mocks/SubscriptionsPresenterMock.swift new file mode 100644 index 000000000..e0c68cba8 --- /dev/null +++ b/Sources/FlareUIMock/Mocks/SubscriptionsPresenterMock.swift @@ -0,0 +1,60 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +@testable import FlareUI +import Foundation + +public final class SubscriptionsPresenterMock: ISubscriptionsPresenter { + public init() {} + + public var invokedViewDidLoad = false + public var invokedViewDidLoadCount = 0 + + public func viewDidLoad() { + invokedViewDidLoad = true + invokedViewDidLoadCount += 1 + } + + public var invokedSelectProduct = false + public var invokedSelectProductCount = 0 + public var invokedSelectProductParameters: (id: String, Void)? + public var invokedSelectProductParametersList = [(id: String, Void)]() + + public func selectProduct(with id: String) { + invokedSelectProduct = true + invokedSelectProductCount += 1 + invokedSelectProductParameters = (id, ()) + invokedSelectProductParametersList.append((id, ())) + } + + public var invokedProduct = false + public var invokedProductCount = 0 + public var invokedProductParameters: (id: String, Void)? + public var invokedProductParametersList = [(id: String, Void)]() + public var stubbedProductResult: StoreProduct! + + public func product(withID id: String) -> StoreProduct? { + invokedProduct = true + invokedProductCount += 1 + invokedProductParameters = (id, ()) + invokedProductParametersList.append((id, ())) + return stubbedProductResult + } + + public var invokedSubscribe = false + public var invokedSubscribeCount = 0 + public var invokedSubscribeParameters: (optionsHandler: PurchaseOptionHandler?, Void)? + public var invokedSubscribeParametersList = [(optionsHandler: PurchaseOptionHandler?, Void)]() + public var stubbedSubscribe: StoreTransaction! + + public func subscribe(optionsHandler: PurchaseOptionHandler?) async throws -> StoreTransaction { + invokedSubscribe = true + invokedSubscribeCount += 1 + invokedSubscribeParameters = (optionsHandler, ()) + invokedSubscribeParametersList.append((optionsHandler, ())) + return stubbedSubscribe + } +} diff --git a/Tests/FlareTests/UnitTests/FlareTests.swift b/Tests/FlareTests/UnitTests/FlareTests.swift index 6998e78bf..a116a97fc 100644 --- a/Tests/FlareTests/UnitTests/FlareTests.swift +++ b/Tests/FlareTests/UnitTests/FlareTests.swift @@ -4,6 +4,7 @@ // @testable import Flare +import FlareMock import StoreKit import XCTest @@ -42,7 +43,7 @@ class FlareTests: XCTestCase { func test_thatFlareFetchesProductsWithGivenProductIDs() { // when - sut.fetch(productIDs: .ids, completion: { _ in }) + sut.fetch(productIDs: Set.ids, completion: { _ in }) // then XCTAssertTrue(iapProviderMock.invokedFetch) @@ -51,14 +52,14 @@ class FlareTests: XCTestCase { func test_thatFlareFetchesProductsWithGivenProductIDs() async throws { // given let productMocks = [ - StoreProduct(skProduct: ProductMock()), - StoreProduct(skProduct: ProductMock()), - StoreProduct(skProduct: ProductMock()), + StoreProduct(ProductMock()), + StoreProduct(ProductMock()), + StoreProduct(ProductMock()), ] iapProviderMock.fetchAsyncResult = productMocks // when - let products = try await sut.fetch(productIDs: .ids) + let products = try await sut.fetch(productIDs: Set.ids) // then XCTAssertEqual(products, productMocks) @@ -69,7 +70,7 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedCanMakePayments = true // when - sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) + sut.purchase(product: .fake(productIdentifier: .productID), completion: { _ in }) // then XCTAssertTrue(iapProviderMock.invokedPurchaseWithPromotionalOffer) @@ -81,7 +82,7 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedCanMakePayments = false // when - sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) + sut.purchase(product: .fake(productIdentifier: .productID), completion: { _ in }) // then XCTAssertFalse(iapProviderMock.invokedPurchase) @@ -95,7 +96,7 @@ class FlareTests: XCTestCase { // when var transaction: IStoreTransaction? - sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { result in + sut.purchase(product: .fake(productIdentifier: .productID), completion: { result in transaction = result.success }) iapProviderMock.invokedPurchaseParameters?.completion(.success(paymentTransaction)) @@ -113,7 +114,7 @@ class FlareTests: XCTestCase { // when var error: IAPError? - sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { result in + sut.purchase(product: .fake(productIdentifier: .productID), completion: { result in error = result.error }) iapProviderMock.invokedPurchaseParameters?.completion(.failure(errorMock)) @@ -129,7 +130,7 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedAsyncPurchase = StoreTransaction(storeTransaction: StoreTransactionStub()) // when - let iapError: IAPError? = await error(for: { try await sut.purchase(product: .fake(skProduct: .fake(id: .productID))) }) + let iapError: IAPError? = await error(for: { try await sut.purchase(product: .fake(productIdentifier: .productID)) }) // then XCTAssertFalse(iapProviderMock.invokedAsyncPurchase) @@ -144,7 +145,7 @@ class FlareTests: XCTestCase { iapProviderMock.stubbedPurchaseAsyncWithPromotionalOffer = transactionMock // when - let transaction = await value(for: { try await sut.purchase(product: .fake(skProduct: .fake(id: .productID))) }) + let transaction = await value(for: { try await sut.purchase(product: .fake(productIdentifier: .productID)) }) // then XCTAssertTrue(iapProviderMock.invokedPurchaseAsyncWithPromotionalOffer) @@ -216,6 +217,17 @@ class FlareTests: XCTestCase { XCTAssertTrue(iapProviderMock.invokedFinishTransaction) } + func test_thatFlareFinishesAsyncTransaction() async { + // given + let transaction = PaymentTransaction(PaymentTransactionMock()) + + // when + await sut.finish(transaction: StoreTransaction(paymentTransaction: transaction)) + + // then + XCTAssertTrue(iapProviderMock.invokedFinishAsyncTransaction) + } + func test_thatFlareAddsTransactionObserver() { // when sut.addTransactionObserver(fallbackHandler: { _ in }) diff --git a/Tests/FlareTests/UnitTests/Models/PaymentTransactionTests.swift b/Tests/FlareTests/UnitTests/Models/PaymentTransactionTests.swift index b5345c068..5d9743631 100644 --- a/Tests/FlareTests/UnitTests/Models/PaymentTransactionTests.swift +++ b/Tests/FlareTests/UnitTests/Models/PaymentTransactionTests.swift @@ -4,6 +4,7 @@ // @testable import Flare +import FlareMock import struct StoreKit.SKError import class StoreKit.SKPaymentTransaction import enum StoreKit.SKPaymentTransactionState diff --git a/Tests/FlareTests/UnitTests/Models/SKProductTests.swift b/Tests/FlareTests/UnitTests/Models/SKProductTests.swift index b901e091e..77c9aab31 100644 --- a/Tests/FlareTests/UnitTests/Models/SKProductTests.swift +++ b/Tests/FlareTests/UnitTests/Models/SKProductTests.swift @@ -4,6 +4,7 @@ // @testable import Flare +import FlareMock import Foundation import class StoreKit.SKProduct import XCTest @@ -13,7 +14,7 @@ import XCTest final class SKProductTests: XCTestCase { func test_thatSKProductFormatsPriceValueAccoringToLocale() { // given - let product = ProductMock() + let product = SKProductMock() product.stubbedPrice = NSDecimalNumber(value: UInt.price) product.stubbedPriceLocale = Locale(identifier: .localeID) diff --git a/Tests/FlareTests/UnitTests/Providers/CachingProductsProviderDecoratorTests.swift b/Tests/FlareTests/UnitTests/Providers/CachingProductsProviderDecoratorTests.swift index c3c779602..7e16e2e08 100644 --- a/Tests/FlareTests/UnitTests/Providers/CachingProductsProviderDecoratorTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/CachingProductsProviderDecoratorTests.swift @@ -40,7 +40,7 @@ final class CachingProductsProviderDecoratorTests: XCTestCase { func test_thatProviderFetchesCachedProducts_whenFetchCachePolicyIsCachedOrFetch() { // given configurationProviderMock.stubbedFetchCachePolicy = .cachedOrFetch - productProviderMock.stubbedFetchResult = .success([StoreProduct.fake()]) + productProviderMock.stubbedFetchResult = .success([StoreProduct.fake(productIdentifier: .productID)]) // when sut.fetch(productIDs: [.productID], requestID: "", completion: { _ in }) diff --git a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift index ace7cd0e8..3db90c719 100644 --- a/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/IAPProviderTests.swift @@ -4,6 +4,7 @@ // @testable import Flare +import FlareMock import StoreKit import XCTest @@ -64,17 +65,17 @@ class IAPProviderTests: XCTestCase { try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() // when - sut.fetch(productIDs: .productIDs, completion: { _ in }) + sut.fetch(productIDs: Set.productIDs, completion: { _ in }) // then let parameters = try XCTUnwrap(productProviderMock.invokedFetchParameters) - XCTAssertEqual(parameters.productIDs, .productIDs) + XCTAssertEqual(parameters.productIDs as? Set, Set.productIDs) XCTAssertTrue(!parameters.requestID.isEmpty) } func test_thatIAPProviderPurchasesProduct() throws { // when - sut.purchase(product: .fake(skProduct: .fake(id: .productID)), completion: { _ in }) + sut.purchase(product: .fake(productIdentifier: .productID), completion: { _ in }) // then XCTAssertTrue(purchaseProvider.invokedPurchase) @@ -99,6 +100,17 @@ class IAPProviderTests: XCTestCase { XCTAssertTrue(purchaseProvider.invokedFinish) } + func test_thatIAPProviderFinishesAsyncTransaction() async { + // given + let transaction = PurchaseManagerTestHelper.makePaymentTransaction(state: .purchased) + + // when + await sut.finish(transaction: StoreTransaction(paymentTransaction: PaymentTransaction(transaction))) + + // then + XCTAssertTrue(purchaseProvider.invokedFinish) + } + func test_thatIAPProviderAddsTransactionObserver() { // when sut.addTransactionObserver(fallbackHandler: { _ in }) @@ -120,11 +132,11 @@ class IAPProviderTests: XCTestCase { try AvailabilityChecker.iOS15APINotAvailableOrSkipTest() // given - let productsMock = [0 ... 2].map { _ in StoreProduct(SK1StoreProduct(ProductMock())) } + let productsMock = [0 ... 2].map { _ in StoreProduct(SK1StoreProduct(SKProductMock())) } productProviderMock.stubbedFetchResult = .success(productsMock) // when - let products = try await sut.fetch(productIDs: .productIDs) + let products = try await sut.fetch(productIDs: Set.productIDs) // then XCTAssertEqual(productsMock.count, products.count) @@ -137,7 +149,7 @@ class IAPProviderTests: XCTestCase { productProviderMock.stubbedFetchResult = .failure(IAPError.unknown) // when - let errorResult: Error? = await error(for: { try await sut.fetch(productIDs: .productIDs) }) + let errorResult: Error? = await error(for: { try await sut.fetch(productIDs: Set.productIDs) }) // then XCTAssertEqual(errorResult as? NSError, IAPError.unknown as NSError) @@ -145,12 +157,12 @@ class IAPProviderTests: XCTestCase { func test_thatIAPProviderReturnsError_whenAddingPaymentFailed() { // given - productProviderMock.stubbedFetchResult = .success([StoreProduct(SK1StoreProduct(ProductMock()))]) + productProviderMock.stubbedFetchResult = .success([StoreProduct(SK1StoreProduct(SKProductMock()))]) purchaseProvider.stubbedPurchaseCompletionResult = (.failure(.unknown), ()) // when var error: Error? - sut.purchase(product: .fake(skProduct: .fake(id: .productID))) { error = $0.error } + sut.purchase(product: .fake(productIdentifier: .productID)) { error = $0.error } // then XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) @@ -162,7 +174,7 @@ class IAPProviderTests: XCTestCase { // when var error: Error? - sut.purchase(product: .fake(skProduct: .fake(id: .productID))) { error = $0.error } + sut.purchase(product: .fake(productIdentifier: .productID)) { error = $0.error } // then XCTAssertEqual(error as? NSError, IAPError.unknown as NSError) diff --git a/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift index 497601be2..9b3bba729 100644 --- a/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/ProductProviderTests.swift @@ -47,7 +47,7 @@ final class ProductProviderTests: XCTestCase { response.stubbedInvokedInvalidProductsIdentifiers = [.productID] // when - sut.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) + sut.fetch(productIDs: Set.productIDs, requestID: .requestID, completion: completionHandler) sut.productsRequest(request, didReceive: response) // then @@ -66,7 +66,7 @@ final class ProductProviderTests: XCTestCase { let response = ProductResponseMock() // when - sut.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) + sut.fetch(productIDs: Set.productIDs, requestID: .requestID, completion: completionHandler) sut.productsRequest(request, didReceive: response) // then @@ -81,7 +81,7 @@ final class ProductProviderTests: XCTestCase { let errorStub = IAPError.unknown // when - sut.fetch(productIDs: .productIDs, requestID: .requestID, completion: completionHandler) + sut.fetch(productIDs: Set.productIDs, requestID: .requestID, completion: completionHandler) sut.request(request, didFailWithError: errorStub) // then diff --git a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift index dee0878b7..eba48d7d5 100644 --- a/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift +++ b/Tests/FlareTests/UnitTests/Providers/PurchaseProviderTests.swift @@ -4,6 +4,7 @@ // @testable import Flare +import FlareMock import StoreKit import StoreKitTest import XCTest @@ -41,7 +42,7 @@ final class PurchaseProviderTests: XCTestCase { func test_thatPurchaseProviderReturnsPaymentTransaction_whenSK1ProductExist() { // given - let productMock = StoreProduct(skProduct: ProductMock()) + let productMock = StoreProduct(ProductMock()) let paymentTransaction = SKPaymentTransaction() let storeTransaction = StoreTransaction(paymentTransaction: PaymentTransaction(paymentTransaction)) diff --git a/Tests/FlareTests/UnitTests/Providers/SortingProductsProviderDecoratorTests.swift b/Tests/FlareTests/UnitTests/Providers/SortingProductsProviderDecoratorTests.swift new file mode 100644 index 000000000..75ea23512 --- /dev/null +++ b/Tests/FlareTests/UnitTests/Providers/SortingProductsProviderDecoratorTests.swift @@ -0,0 +1,79 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import Flare +import FlareMock +import XCTest + +// MARK: - SortingProductsProviderDecoratorTests + +final class SortingProductsProviderDecoratorTests: XCTestCase { + // MARK: Properties + + private var productProviderMock: ProductProviderMock! + + private var sut: SortingProductsProviderDecorator! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + productProviderMock = ProductProviderMock() + sut = SortingProductsProviderDecorator(productProvider: productProviderMock) + } + + override func tearDown() { + productProviderMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_ProductProviderSortsSetItems_whenFetchProducts() { + test_sort(collection: Set.productIDs) + } + + func test_ProductProviderSortsArrayItems_whenFetchProducts() { + test_sort(collection: Array.productIDs) + } + + // MARK: Private + + private func test_sort(collection: some Collection) { + // given + let ids = collection + let products: [StoreProduct] = ids + .map { .fake(productIdentifier: $0) } + .shuffled() + productProviderMock.stubbedFetchResult = .success(products) + + // when + var resultProducts: [StoreProduct] = [] + sut.fetch(productIDs: ids, requestID: .requestID) { result in + if case let .success(products) = result { + resultProducts = products + } + } + + // then + XCTAssertEqual(ids.count, resultProducts.count) + XCTAssertEqual(Array(ids), resultProducts.map(\.productIdentifier)) + } +} + +// MARK: - Constants + +private extension String { + static let requestID = "requestID" +} + +private extension Array where Element == String { + static let productIDs: [Element] = ["1", "2", "3"] +} + +private extension Set where Element == String { + static let productIDs: Set = .init(arrayLiteral: "1", "2", "3") +} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Fakes/SKProduct+Fake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/SKProduct+Fake.swift index a748b6270..d85f09fcf 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Fakes/SKProduct+Fake.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/SKProduct+Fake.swift @@ -1,14 +1,15 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // +import FlareMock import Foundation import StoreKit extension SKProduct { static func fake(id: String) -> SKProduct { - let product = ProductMock() + let product = SKProductMock() product.stubbedProductIdentifier = id return product } diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreProduct+Fake.swift b/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreProduct+Fake.swift deleted file mode 100644 index 4f93ff255..000000000 --- a/Tests/FlareTests/UnitTests/TestHelpers/Fakes/StoreProduct+Fake.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -import Flare -import StoreKit - -extension StoreProduct { - static func fake(skProduct: SKProduct = ProductMock()) -> StoreProduct { - StoreProduct(skProduct: skProduct) - } -} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Helpers/PurchaseManagerTestHelper.swift b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/PurchaseManagerTestHelper.swift index 1d67b7f43..63201f958 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Helpers/PurchaseManagerTestHelper.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Helpers/PurchaseManagerTestHelper.swift @@ -1,9 +1,10 @@ // // Flare -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // @testable import Flare +import FlareMock import StoreKit enum PurchaseManagerTestHelper { @@ -23,7 +24,7 @@ enum PurchaseManagerTestHelper { } static func makeProduct(with productIdentifier: String) -> SKProduct { - let product = ProductMock() + let product = SKProductMock() product.stubbedProductIdentifier = productIdentifier return product } diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift index 1d05ea36c..296b91b41 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/IAPProviderMock.swift @@ -19,10 +19,10 @@ final class IAPProviderMock: IIAPProvider { var invokedFetch = false var invokedFetchCount = 0 - var invokedFetchParameters: (productIDs: Set, completion: Closure>)? - var invokedFetchParametersList = [(productIDs: Set, completion: Closure>)]() + var invokedFetchParameters: (productIDs: Any, completion: Closure>)? + var invokedFetchParametersList = [(productIDs: Any, completion: Closure>)]() - func fetch(productIDs: Set, completion: @escaping Closure>) { + func fetch(productIDs: some Collection, completion: @escaping Closure>) { invokedFetch = true invokedFetchCount += 1 invokedFetchParameters = (productIDs, completion) @@ -70,12 +70,24 @@ final class IAPProviderMock: IIAPProvider { invokedFinishTransactionParanetersList.append((transaction, ())) } + var invokedFinishAsyncTransaction = false + var invokedFinishAsyncTransactionCount = 0 + var invokedFinishAsyncTransactionParameters: (StoreTransaction, Void)? + var invokedFinishAsyncTransactionParanetersList = [(StoreTransaction, Void)]() + + func finish(transaction: StoreTransaction) async { + invokedFinishAsyncTransaction = true + invokedFinishAsyncTransactionCount += 1 + invokedFinishAsyncTransactionParameters = (transaction, ()) + invokedFinishAsyncTransactionParanetersList = [(transaction, ())] + } + var invokedAddTransactionObserver = false var invokedAddTransactionObserverCount = 0 - var invokedAddTransactionObserverParameters: (fallbackHandler: Closure>?, Void)? - var invokedAddTransactionObserverParametersList = [(fallbackHandler: Closure>?, Void)]() + var invokedAddTransactionObserverParameters: (fallbackHandler: Closure>?, Void)? + var invokedAddTransactionObserverParametersList = [(fallbackHandler: Closure>?, Void)]() - func addTransactionObserver(fallbackHandler: Closure>?) { + func addTransactionObserver(fallbackHandler: Closure>?) { invokedAddTransactionObserver = true invokedAddTransactionObserverCount += 1 invokedAddTransactionObserverParameters = (fallbackHandler, ()) @@ -92,11 +104,11 @@ final class IAPProviderMock: IIAPProvider { var invokedFetchAsync = false var invokedFetchAsyncCount = 0 - var invokedFetchAsyncParameters: (productIDs: Set, Void)? - var invokedFetchAsyncParametersList = [(productIDs: Set, Void)]() + var invokedFetchAsyncParameters: (productIDs: Any, Void)? + var invokedFetchAsyncParametersList = [(productIDs: Any, Void)]() var fetchAsyncResult: [StoreProduct] = [] - func fetch(productIDs: Set) async throws -> [StoreProduct] { + func fetch(productIDs: some Collection) async throws -> [StoreProduct] { invokedFetchAsync = true invokedFetchAsyncCount += 1 invokedFetchAsyncParameters = (productIDs, ()) @@ -284,4 +296,7 @@ final class IAPProviderMock: IIAPProvider { invokedPresentOfferCodeRedeemSheet = true invokedPresentOfferCodeRedeemSheetCount += 1 } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func restore() async throws {} } diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentTransactionMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentTransactionMock.swift deleted file mode 100644 index efae4bd7d..000000000 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PaymentTransactionMock.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// Flare -// Copyright © 2023 Space Code. All rights reserved. -// - -import StoreKit - -final class PaymentTransactionMock: SKPaymentTransaction { - var invokedTransactionState = false - var invokedTransactionStateCount = 0 - var stubbedTransactionState: SKPaymentTransactionState! - - override var transactionState: SKPaymentTransactionState { - stubbedTransactionState - } - - var invokedTransactionIndentifier = false - var invokedTransactionIndentifierCount = 0 - var stubbedTransactionIndentifier: String? - - override var transactionIdentifier: String? { - invokedTransactionIndentifier = true - invokedTransactionStateCount += 1 - return stubbedTransactionIndentifier - } - - var invokedPayment = false - var invokedPaymentCount = 0 - var stubbedPayment: SKPayment! - - override var payment: SKPayment { - invokedPayment = true - invokedPaymentCount += 1 - return stubbedPayment - } - - var stubbedOriginal: SKPaymentTransaction? - override var original: SKPaymentTransaction? { - stubbedOriginal - } - - var stubbedError: Error? - override var error: Error? { - stubbedError - } -} diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift index 489118ce7..16bd75bfd 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductProviderMock.swift @@ -9,11 +9,11 @@ import StoreKit final class ProductProviderMock: IProductProvider { var invokedFetch = false var invokedFetchCount = 0 - var invokedFetchParameters: (productIDs: Set, requestID: String, completion: ProductsHandler)? - var invokedFetchParamtersList = [(productIDs: Set, requestID: String, completion: ProductsHandler)]() + var invokedFetchParameters: (productIDs: Any, requestID: String, completion: ProductsHandler)? + var invokedFetchParamtersList = [(productIDs: Any, requestID: String, completion: ProductsHandler)]() var stubbedFetchResult: Result<[StoreProduct], IAPError>? - func fetch(productIDs: Set, requestID: String, completion: @escaping ProductsHandler) { + func fetch(productIDs: some Collection, requestID: String, completion: @escaping ProductsHandler) { invokedFetch = true invokedFetchCount += 1 invokedFetchParameters = (productIDs, requestID, completion) @@ -26,12 +26,12 @@ final class ProductProviderMock: IProductProvider { var invokedAsyncFetch = false var invokedAsyncFetchCount = 0 - var invokedAsyncFetchParameters: (productIDs: Set, Void)? - var invokedAsyncFetchParamtersList = [(productIDs: Set, Void)]() + var invokedAsyncFetchParameters: (productIDs: Any, Void)? + var invokedAsyncFetchParamtersList = [(productIDs: Any, Void)]() var stubbedAsyncFetchResult: Result<[StoreProduct], Error>? @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) - func fetch(productIDs: Set) async throws -> [StoreProduct] { + func fetch(productIDs: some Collection) async throws -> [StoreProduct] { invokedAsyncFetch = true invokedAsyncFetchCount += 1 invokedAsyncFetchParameters = (productIDs, ()) diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift index 7ccf9b076..f58fd3121 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift @@ -12,19 +12,20 @@ final class PurchaseProviderMock: IPurchaseProvider { var invokedFinishParameters: (transaction: StoreTransaction, Void)? var invokedFinishParametersList = [(transaction: StoreTransaction, Void)]() - func finish(transaction: StoreTransaction, completion _: (@Sendable () -> Void)?) { + func finish(transaction: StoreTransaction, completion: (@Sendable () -> Void)?) { invokedFinish = true invokedFinishCount += 1 invokedFinishParameters = (transaction, ()) invokedFinishParametersList.append((transaction, ())) + completion?() } var invokedAddTransactionObserver = false var invokedAddTransactionObserverCount = 0 - var invokedAddTransactionObserverParameters: (fallbackHandler: Closure>?, Void)? - var invokedAddTransactionObserverParametersList = [(fallbackHandler: Closure>?, Void)]() + var invokedAddTransactionObserverParameters: (fallbackHandler: Closure>?, Void)? + var invokedAddTransactionObserverParametersList = [(fallbackHandler: Closure>?, Void)]() - func addTransactionObserver(fallbackHandler: Closure>?) { + func addTransactionObserver(fallbackHandler: Closure>?) { invokedAddTransactionObserver = true invokedAddTransactionObserverCount += 1 invokedAddTransactionObserverParameters = (fallbackHandler, ()) @@ -79,4 +80,7 @@ final class PurchaseProviderMock: IPurchaseProvider { completion(result.0) } } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func restore() async throws {} } diff --git a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductMock.swift b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/SKProductMock.swift similarity index 94% rename from Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductMock.swift rename to Tests/FlareTests/UnitTests/TestHelpers/Mocks/SKProductMock.swift index 9d765f02e..74e27fcb4 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/ProductMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/SKProductMock.swift @@ -5,7 +5,7 @@ import StoreKit -final class ProductMock: SKProduct { +final class SKProductMock: SKProduct { var invokedProductIdentifier = false var invokedProductIdentifierCount = 0 var stubbedProductIdentifier: String = "product_id" diff --git a/Tests/FlareUITests/UnitTests/Core/Extensions/ArrayExtensionsTests.swift b/Tests/FlareUITests/UnitTests/Core/Extensions/ArrayExtensionsTests.swift new file mode 100644 index 000000000..d2ea3a48c --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Core/Extensions/ArrayExtensionsTests.swift @@ -0,0 +1,20 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import FlareUI +import XCTest + +final class ArrayExtensionsTests: XCTestCase { + func test_thatArrayRemovesDuplicates() { + // given + let array = [10, 10, 9, 1, 3, 3, 7, 8, 7, 7, 7] + + // when + let filteredArray = array.removingDuplicates() + + // then + XCTAssertEqual(filteredArray, [10, 9, 1, 3, 7, 8]) + } +} diff --git a/Tests/FlareUITests/UnitTests/Core/SubscriptionPriceViewModelFactoryTests.swift b/Tests/FlareUITests/UnitTests/Core/SubscriptionPriceViewModelFactoryTests.swift new file mode 100644 index 000000000..1b404acac --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Core/SubscriptionPriceViewModelFactoryTests.swift @@ -0,0 +1,94 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import FlareMock +@testable import FlareUI +import XCTest + +// MARK: - SubscriptionPriceViewModelFactoryTests + +final class SubscriptionPriceViewModelFactoryTests: XCTestCase { + // MARK: Properties + + private var dateComponentsFormatterMock: DateComponentsFormatterMock! + private var subscriptionDateComponentsFactoryMock: SubscriptionDateComponentsFactoryMock! + + private var sut: SubscriptionPriceViewModelFactory! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + dateComponentsFormatterMock = DateComponentsFormatterMock() + subscriptionDateComponentsFactoryMock = SubscriptionDateComponentsFactoryMock() + sut = SubscriptionPriceViewModelFactory( + dateFormatter: dateComponentsFormatterMock, + subscriptionDateComponentsFactory: subscriptionDateComponentsFactoryMock + ) + } + + override func tearDown() { + dateComponentsFormatterMock = nil + subscriptionDateComponentsFactoryMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatFactoryMakesAProduct_whenProductIsConsumable() { + // given + let product: StoreProduct = .fake(productType: .consumable) + + // when + let viewModel = sut.make(product, format: .short) + + // then + XCTAssertEqual(viewModel, product.localizedPriceString) + } + + func test_thatFactoryMakesProductWithCompactStyle_whenProductTypeIsRenewableSubscription() { + // given + subscriptionDateComponentsFactoryMock.stubbedDateComponentsResult = DateComponents(day: 1) + dateComponentsFormatterMock.stubbedStringResult = "1 month" + + let product: StoreProduct = .fake( + localizedPriceString: .price, + productType: .autoRenewableSubscription, + subscriptionPeriod: .init(value: 1, unit: .day) + ) + + // when + let viewModel = sut.make(product, format: .short) + + // then + XCTAssertEqual(viewModel, "10 $") + } + + func test_thatFactoryMakesProductWithLargeStyle_whenProductTypeIsRenewableSubscription() { + // given + subscriptionDateComponentsFactoryMock.stubbedDateComponentsResult = DateComponents(day: 1) + dateComponentsFormatterMock.stubbedStringResult = "1 month" + + let product: StoreProduct = .fake( + localizedPriceString: .price, + productType: .autoRenewableSubscription, + subscriptionPeriod: .init(value: 1, unit: .day) + ) + + // when + let viewModel = sut.make(product, format: .full) + + // then + XCTAssertEqual(viewModel, "10 $/month") + } +} + +// MARK: - Constants + +private extension String { + static let price = "10 $" +} diff --git a/Tests/FlareUITests/UnitTests/Fakes/SubscriptionView.ViewModel+Fake.swift b/Tests/FlareUITests/UnitTests/Fakes/SubscriptionView.ViewModel+Fake.swift new file mode 100644 index 000000000..61bfeb930 --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Fakes/SubscriptionView.ViewModel+Fake.swift @@ -0,0 +1,20 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import FlareUI +import Foundation + +@available(watchOS, unavailable) +extension SubscriptionView.ViewModel { + static func fake(id: String? = nil) -> SubscriptionView.ViewModel { + SubscriptionView.ViewModel( + id: id ?? UUID().uuidString, + title: "Title", + price: "5,99$", + description: "Description", + isActive: true + ) + } +} diff --git a/Tests/FlareUITests/UnitTests/Helpers/XCTestCase+.swift b/Tests/FlareUITests/UnitTests/Helpers/XCTestCase+.swift new file mode 100644 index 000000000..3e1c6feef --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Helpers/XCTestCase+.swift @@ -0,0 +1,35 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import XCTest + +extension XCTestCase { + func value(for closure: () async throws -> U) async -> U? { + do { + let value = try await closure() + return value + } catch { + return nil + } + } + + func error(for closure: () async throws -> U) async -> T? { + do { + _ = try await closure() + return nil + } catch { + return error as? T + } + } + + func result(for closure: () async throws -> U) async -> Result { + do { + let value = try await closure() + return .success(value) + } catch { + return .failure(error as! T) + } + } +} diff --git a/Tests/FlareUITests/UnitTests/Helpers/XCTestCase+Wait.swift b/Tests/FlareUITests/UnitTests/Helpers/XCTestCase+Wait.swift new file mode 100644 index 000000000..d2f99c8b5 --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Helpers/XCTestCase+Wait.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import XCTest + +extension XCTestCase { + func wait( + _ condition: @escaping @autoclosure () -> (Bool), + timeout: TimeInterval = 10 + ) { + wait( + for: [ + XCTNSPredicateExpectation( + predicate: NSPredicate(block: { _, _ in condition() }), object: nil + ), + ], + timeout: timeout + ) + } +} diff --git a/Tests/FlareUITests/UnitTests/Mocks/DateComponentsFormatterMock.swift b/Tests/FlareUITests/UnitTests/Mocks/DateComponentsFormatterMock.swift new file mode 100644 index 000000000..645034dc2 --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Mocks/DateComponentsFormatterMock.swift @@ -0,0 +1,45 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import FlareUI +import Foundation + +final class DateComponentsFormatterMock: IDateComponentsFormatter { + var invokedAllowedUnitsSetter = false + var invokedAllowedUnitsSetterCount = 0 + var invokedAllowedUnits: NSCalendar.Unit? + var invokedAllowedUnitsList = [NSCalendar.Unit]() + var invokedAllowedUnitsGetter = false + var invokedAllowedUnitsGetterCount = 0 + var stubbedAllowedUnits: NSCalendar.Unit! + + var allowedUnits: NSCalendar.Unit { + set { + invokedAllowedUnitsSetter = true + invokedAllowedUnitsSetterCount += 1 + invokedAllowedUnits = newValue + invokedAllowedUnitsList.append(newValue) + } + get { + invokedAllowedUnitsGetter = true + invokedAllowedUnitsGetterCount += 1 + return stubbedAllowedUnits + } + } + + var invokedString = false + var invokedStringCount = 0 + var invokedStringParameters: (from: DateComponents, Void)? + var invokedStringParametersList = [(from: DateComponents, Void)]() + var stubbedStringResult: String! + + func string(from: DateComponents) -> String? { + invokedString = true + invokedStringCount += 1 + invokedStringParameters = (from, ()) + invokedStringParametersList.append((from, ())) + return stubbedStringResult + } +} diff --git a/Tests/FlareUITests/UnitTests/Mocks/ProductFetcherMock.swift b/Tests/FlareUITests/UnitTests/Mocks/ProductFetcherMock.swift new file mode 100644 index 000000000..4b7dcb476 --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Mocks/ProductFetcherMock.swift @@ -0,0 +1,24 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +@testable import FlareUI +import Foundation + +final class ProductFetcherMock: IProductFetcherStrategy { + var invokedProduct = false + var invokedProductCount = 0 + var stubbedThrowProduct: Error? + var stubbedProduct: StoreProduct! + + func product() async throws -> StoreProduct { + invokedProduct = true + invokedProductCount += 1 + if let stubbedThrowProduct = stubbedThrowProduct { + throw stubbedThrowProduct + } + return stubbedProduct + } +} diff --git a/Tests/FlareUITests/UnitTests/Mocks/ProductPurchaseServiceMock.swift b/Tests/FlareUITests/UnitTests/Mocks/ProductPurchaseServiceMock.swift new file mode 100644 index 000000000..daf1a08aa --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Mocks/ProductPurchaseServiceMock.swift @@ -0,0 +1,27 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +@testable import FlareUI + +final class ProductPurchaseServiceMock: IProductPurchaseService { + var invokedPurchase = false + var invokedPurchaseCount = 0 + var invokedPurchaseParameters: (product: StoreProduct, options: PurchaseOptions?)? + var invokedPurchaseParametersList = [(product: StoreProduct, options: PurchaseOptions?)]() + var stubbedPurchaseError: Error? + var stubbedPurchase: StoreTransaction = .fake() + + func purchase(product: StoreProduct, options: PurchaseOptions?) async throws -> StoreTransaction { + invokedPurchase = true + invokedPurchaseCount += 1 + invokedPurchaseParameters = (product, options) + invokedPurchaseParametersList.append((product, options)) + if let stubbedPurchaseError { + throw stubbedPurchaseError + } + return stubbedPurchase + } +} diff --git a/Tests/FlareUITests/UnitTests/Mocks/SubscriptionDateComponentsFactoryMock.swift b/Tests/FlareUITests/UnitTests/Mocks/SubscriptionDateComponentsFactoryMock.swift new file mode 100644 index 000000000..57820de3b --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Mocks/SubscriptionDateComponentsFactoryMock.swift @@ -0,0 +1,24 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +@testable import FlareUI +import Foundation + +final class SubscriptionDateComponentsFactoryMock: ISubscriptionDateComponentsFactory { + var invokedDateComponents = false + var invokedDateComponentsCount = 0 + var invokedDateComponentsParameters: (subscription: SubscriptionPeriod, Void)? + var invokedDateComponentsParametersList = [(subscription: SubscriptionPeriod, Void)]() + var stubbedDateComponentsResult: DateComponents! + + func dateComponents(for subscription: SubscriptionPeriod) -> DateComponents { + invokedDateComponents = true + invokedDateComponentsCount += 1 + invokedDateComponentsParameters = (subscription, ()) + invokedDateComponentsParametersList.append((subscription, ())) + return stubbedDateComponentsResult + } +} diff --git a/Tests/FlareUITests/UnitTests/Mocks/SubscriptionPriceViewModelFactoryMock.swift b/Tests/FlareUITests/UnitTests/Mocks/SubscriptionPriceViewModelFactoryMock.swift new file mode 100644 index 000000000..ecf02d419 --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Mocks/SubscriptionPriceViewModelFactoryMock.swift @@ -0,0 +1,37 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +@testable import FlareUI + +final class SubscriptionPriceViewModelFactoryMock: ISubscriptionPriceViewModelFactory { + var invokedMake = false + var invokedMakeCount = 0 + var invokedMakeParameters: (product: StoreProduct, format: PriceDisplayFormat)? + var invokedMakeParametersList = [(product: StoreProduct, format: PriceDisplayFormat)]() + var stubbedMakeResult: String! = "" + + func make(_ product: StoreProduct, format: PriceDisplayFormat) -> String { + invokedMake = true + invokedMakeCount += 1 + invokedMakeParameters = (product, format) + invokedMakeParametersList.append((product, format)) + return stubbedMakeResult + } + + var invokedPeriod = false + var invokedPeriodCount = 0 + var invokedPeriodParameters: (product: StoreProduct, Void)? + var invokedPeriodParametersList = [(product: StoreProduct, Void)]() + var stubbedPeriodResult: String! + + func period(from product: StoreProduct) -> String? { + invokedPeriod = true + invokedPeriodCount += 1 + invokedPeriodParameters = (product, ()) + invokedPeriodParametersList.append((product, ())) + return stubbedPeriodResult + } +} diff --git a/Tests/FlareUITests/UnitTests/Mocks/SubscriptionsViewModelViewFactoryMock.swift b/Tests/FlareUITests/UnitTests/Mocks/SubscriptionsViewModelViewFactoryMock.swift new file mode 100644 index 000000000..bb7e345f3 --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Mocks/SubscriptionsViewModelViewFactoryMock.swift @@ -0,0 +1,24 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +@testable import FlareUI + +@available(watchOS, unavailable) +final class SubscriptionsViewModelViewFactoryMock: ISubscriptionsViewModelViewFactory { + var invokedMake = false + var invokedMakeCount = 0 + var invokedMakeParameters: (products: [StoreProduct], Void)? + var invokedMakeParametersList = [(products: [StoreProduct], Void)]() + var stubbedMake: [SubscriptionView.ViewModel] = [] + + func make(_ products: [StoreProduct]) async throws -> [SubscriptionView.ViewModel] { + invokedMake = true + invokedMakeCount += 1 + invokedMakeParameters = (products, ()) + invokedMakeParametersList.append((products, ())) + return stubbedMake + } +} diff --git a/Tests/FlareUITests/UnitTests/Presentation/Product/ProductPresenterTests.swift b/Tests/FlareUITests/UnitTests/Presentation/Product/ProductPresenterTests.swift new file mode 100644 index 000000000..3cf6091b1 --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Presentation/Product/ProductPresenterTests.swift @@ -0,0 +1,88 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import FlareMock +@testable import FlareUI +import FlareUIMock +import XCTest + +final class ProductPresenterTests: XCTestCase { + // MARK: Properties + + private var purchaseServiceMock: ProductPurchaseServiceMock! + private var productFetcherMock: ProductFetcherMock! + private var viewModelMock: WrapperViewModel! + + private var sut: ProductPresenter! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + purchaseServiceMock = ProductPurchaseServiceMock() + productFetcherMock = ProductFetcherMock() + sut = ProductPresenter( + productFetcher: productFetcherMock, + purchaseService: purchaseServiceMock + ) + viewModelMock = WrapperViewModel(model: ProductViewModel(state: .loading, presenter: sut)) + sut.viewModel = viewModelMock + } + + override func tearDown() { + sut = nil + productFetcherMock = nil + purchaseServiceMock = nil + viewModelMock = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatPresenterFetchesProduct_whenViewDidLoad() async { + // given + let productFake = StoreProduct.fake() + productFetcherMock.stubbedProduct = productFake + + // when + sut.viewDidLoad() + + // then + wait(self.viewModelMock.model.state == .product(productFake)) + } + + func test_thatPresenterDisplaysAnError_whenViewDidLoad() async { + // given + productFetcherMock.stubbedThrowProduct = IAPError.unknown + + // when + sut.viewDidLoad() + + // then + wait(self.viewModelMock.model.state == .error(.unknown)) + } + + func test_thatPresenterThrowsAnErrorIfProductIsMissing_whenPurchase() async throws { + // when + let error: Error? = await error(for: { try await sut.purchase(options: nil) }) + + // then + let iapError = try XCTUnwrap(error as? IAPError) + XCTAssertEqual(iapError, .unknown) + } + + func test_thatPresenterFinishesTransaction_whenPurchase() async throws { + // given + viewModelMock.model = .init(state: .product(.fake()), presenter: sut) + purchaseServiceMock.stubbedPurchase = .fake() + + // when + _ = try await sut.purchase(options: nil) + + // then + XCTAssertEqual(purchaseServiceMock.invokedPurchaseCount, 1) + } +} diff --git a/Tests/FlareUITests/UnitTests/Presentation/Product/ProductStrategyTests.swift b/Tests/FlareUITests/UnitTests/Presentation/Product/ProductStrategyTests.swift new file mode 100644 index 000000000..4c451cdc5 --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Presentation/Product/ProductStrategyTests.swift @@ -0,0 +1,66 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import FlareMock +@testable import FlareUI +import FlareUIMock +import XCTest + +final class ProductStrategyTests: XCTestCase { + // MARK: Properties + + private var iapMock: FlareMock! + + private var sut: ProductStrategy! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + iapMock = FlareMock() + } + + override func tearDown() { + iapMock = nil + super.tearDown() + } + + // MARK: Tests + + func test_strategyReturnsProduct() async throws { + // given + let productFake = StoreProduct.fake() + let sut = prepareSut(type: .product(productFake)) + + // when + let product = try await sut.product() + + // then + XCTAssertEqual(product, productFake) + XCTAssertEqual(iapMock.invokedFetchCount, 0) + } + + func test_strategyFetchesProduct() async throws { + // given + let productFake = StoreProduct.fake() + iapMock.stubbedInvokedFetch = [productFake] + + let sut = prepareSut(type: .productID(productFake.productIdentifier)) + + // when + let product = try await sut.product() + + // then + XCTAssertEqual(product, productFake) + XCTAssertEqual(iapMock.invokedFetchCount, 1) + } + + // MARK: Private + + private func prepareSut(type: ProductViewType) -> ProductStrategy { + ProductStrategy(type: type, iap: iapMock) + } +} diff --git a/Tests/FlareUITests/UnitTests/Presentation/Product/ProductViewModelFactoryTests.swift b/Tests/FlareUITests/UnitTests/Presentation/Product/ProductViewModelFactoryTests.swift new file mode 100644 index 000000000..473ff8547 --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Presentation/Product/ProductViewModelFactoryTests.swift @@ -0,0 +1,103 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import FlareMock +@testable import FlareUI +import XCTest + +// MARK: - ProductViewModelFactoryTests + +final class ProductViewModelFactoryTests: XCTestCase { + // MARK: Properties + + private var subscriptionPriceViewModelFactoryMock: SubscriptionPriceViewModelFactoryMock! + + private var sut: ProductViewModelFactory! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + subscriptionPriceViewModelFactoryMock = SubscriptionPriceViewModelFactoryMock() + sut = ProductViewModelFactory( + subscriptionPriceViewModelFactory: subscriptionPriceViewModelFactoryMock + ) + } + + override func tearDown() { + subscriptionPriceViewModelFactoryMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatFactoryMakesAProduct_whenProductIsConsumable() { + // given + subscriptionPriceViewModelFactoryMock.stubbedMakeResult = .price + + let product: StoreProduct = .fake(localizedPriceString: .price, productType: .consumable) + + // when + let viewModel = sut.make(product, style: .compact) + + // then + XCTAssertEqual(viewModel.id, product.productIdentifier) + XCTAssertEqual(viewModel.title, product.localizedTitle) + XCTAssertEqual(viewModel.description, product.localizedDescription) + XCTAssertEqual(viewModel.price, product.localizedPriceString) + } + + func test_thatFactoryMakesProductWithCompactStyle_whenProductTypeIsRenewableSubscription() { + // given + subscriptionPriceViewModelFactoryMock.stubbedMakeResult = .price + subscriptionPriceViewModelFactoryMock.stubbedPeriodResult = "Month" + + let product: StoreProduct = .fake( + localizedPriceString: .price, + productType: .autoRenewableSubscription, + subscriptionPeriod: .init(value: 1, unit: .day) + ) + + // when + let viewModel = sut.make(product, style: .compact) + + // then + XCTAssertEqual(viewModel.id, product.productIdentifier) + XCTAssertEqual(viewModel.title, product.localizedTitle) + XCTAssertEqual(viewModel.description, product.localizedDescription) + XCTAssertEqual(viewModel.price, .price) + XCTAssertEqual(viewModel.priceDescription, "Every Month") + } + + func test_thatFactoryMakesProductWithLargeStyle_whenProductTypeIsRenewableSubscription() { + // given + subscriptionPriceViewModelFactoryMock.stubbedMakeResult = .price + subscriptionPriceViewModelFactoryMock.stubbedPeriodResult = "Month" + + let product: StoreProduct = .fake( + localizedPriceString: .price, + productType: .autoRenewableSubscription, + subscriptionPeriod: .init(value: 1, unit: .day) + ) + + // when + let viewModel = sut.make(product, style: .large) + + // then + XCTAssertEqual(viewModel.id, product.productIdentifier) + XCTAssertEqual(viewModel.title, product.localizedTitle) + XCTAssertEqual(viewModel.description, product.localizedDescription) + XCTAssertEqual(viewModel.price, .price) + XCTAssertEqual(viewModel.priceDescription, "Every Month") + } +} + +// MARK: - Constants + +private extension String { + static let price = "10 $" +} diff --git a/Tests/FlareUITests/UnitTests/Presentation/Product/SubscriptionDateComponentsFactoryTests.swift b/Tests/FlareUITests/UnitTests/Presentation/Product/SubscriptionDateComponentsFactoryTests.swift new file mode 100644 index 000000000..ccfaef44f --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Presentation/Product/SubscriptionDateComponentsFactoryTests.swift @@ -0,0 +1,59 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import FlareUI +import XCTest + +final class SubscriptionDateComponentsFactoryTests: XCTestCase { + // MARK: Private + + private var sut: SubscriptionDateComponentsFactory! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + sut = SubscriptionDateComponentsFactory() + } + + override func tearDown() { + sut = nil + super.tearDown() + } + + // MARK: - Tests + + func test_thatDateComponentsFactoryCreatesDateCompoments_whenUnitIsDay() { + // when + let components = sut.dateComponents(for: .init(value: 10, unit: .day)) + + // then + XCTAssertEqual(components.day, 10) + } + + func test_thatDateComponentsFactoryCreatesDateCompoments_whenUnitIsWeak() { + // when + let components = sut.dateComponents(for: .init(value: 10, unit: .week)) + + // then + XCTAssertEqual(components.weekOfMonth, 10) + } + + func test_thatDateComponentsFactoryCreatesDateCompoments_whenUnitIsMonth() { + // when + let components = sut.dateComponents(for: .init(value: 10, unit: .month)) + + // then + XCTAssertEqual(components.month, 10) + } + + func test_thatDateComponentsFactoryCreatesDateCompoments_whenUnitIsYear() { + // when + let components = sut.dateComponents(for: .init(value: 2010, unit: .year)) + + // then + XCTAssertEqual(components.year, 2010) + } +} diff --git a/Tests/FlareUITests/UnitTests/Presentation/Products/ProductsPresenterTests.swift b/Tests/FlareUITests/UnitTests/Presentation/Products/ProductsPresenterTests.swift new file mode 100644 index 000000000..5f80725a3 --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Presentation/Products/ProductsPresenterTests.swift @@ -0,0 +1,70 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import FlareMock +@testable import FlareUI +import FlareUIMock +import XCTest + +final class ProductsPresenterTests: XCTestCase { + // MARK: Properties + + private var iapMock: FlareMock! + private var viewModelMock: WrapperViewModel! + + private var sut: ProductsPresenter! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + iapMock = FlareMock() + sut = ProductsPresenter( + ids: [], + iap: iapMock + ) + viewModelMock = WrapperViewModel( + model: ProductsViewModel( + state: .products([]), + presenter: sut + ) + ) + sut.viewModel = viewModelMock + } + + override func tearDown() { + viewModelMock = nil + iapMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatPresenterFetchesProducts_whenViewDidLoad() { + // given + let product: StoreProduct = .fake() + iapMock.stubbedInvokedFetch = [product] + + // when + sut.viewDidLoad() + + // then + wait(self.viewModelMock.model.state == .products([product])) + XCTAssertEqual(iapMock.invokedFetchCount, 1) + } + + func test_thatPresenterThrowsError_whenViewDidLoad() { + // given + iapMock.stubbedFetchError = IAPError.unknown + + // when + sut.viewDidLoad() + + // then + wait(self.viewModelMock.model.state == .error(.unknown)) + } +} diff --git a/Tests/FlareUITests/UnitTests/Presentation/StoreButton/StoreButtonPresenterTests.swift b/Tests/FlareUITests/UnitTests/Presentation/StoreButton/StoreButtonPresenterTests.swift new file mode 100644 index 000000000..933f66d00 --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Presentation/StoreButton/StoreButtonPresenterTests.swift @@ -0,0 +1,41 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import FlareUI +import FlareUIMock +import XCTest + +final class StoreButtonPresenterTests: XCTestCase { + // MARK: Properties + + private var iapMock: FlareMock! + + private var sut: StoreButtonPresenter! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + iapMock = FlareMock() + sut = StoreButtonPresenter(iap: iapMock) + } + + override func tearDown() { + iapMock = nil + sut = nil + super.tearDown() + } + + // MARK: Tests + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_thatPresenterRestoresTransactions() async throws { + // when + try await sut.restore() + + // then + XCTAssertEqual(iapMock.invokedRestoreCount, 1) + } +} diff --git a/Tests/FlareUITests/UnitTests/Presentation/Subscriptions/SubscriptionsPresenterTests.swift b/Tests/FlareUITests/UnitTests/Presentation/Subscriptions/SubscriptionsPresenterTests.swift new file mode 100644 index 000000000..63f2e4aeb --- /dev/null +++ b/Tests/FlareUITests/UnitTests/Presentation/Subscriptions/SubscriptionsPresenterTests.swift @@ -0,0 +1,132 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +@testable import FlareUI +import FlareUIMock +import XCTest + +// MARK: - SubscriptionsPresenterTests + +@available(watchOS, unavailable) +final class SubscriptionsPresenterTests: XCTestCase { + // MARK: Properties + + private var sut: SubscriptionsPresenter! + + private var viewModelMock: WrapperViewModel! + private var iapMock: FlareMock! + private var viewModelFactoryMock: SubscriptionsViewModelViewFactoryMock! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + + iapMock = FlareMock() + viewModelFactoryMock = SubscriptionsViewModelViewFactoryMock() + + sut = SubscriptionsPresenter( + iap: iapMock, + ids: Array.ids, + viewModelFactory: viewModelFactoryMock + ) + + viewModelMock = WrapperViewModel( + model: SubscriptionsViewModel( + state: .loading, + selectedProductID: nil, + presenter: sut + ) + ) + + sut.viewModel = viewModelMock + } + + override func tearDown() { + iapMock = nil + viewModelFactoryMock = nil + + super.tearDown() + } + + // MARK: Tests + + func test_thatPresenterShowsProducts_whenViewDidLoad() throws { + // given + let autoRenewableProduct = StoreProduct.fake(productType: .autoRenewableSubscription) + + iapMock.stubbedInvokedFetch = [ + autoRenewableProduct, + .fake(productType: .consumable), + .fake(productType: .nonConsumable), + .fake(productType: .nonRenewableSubscription), + ] + viewModelFactoryMock.stubbedMake = [.fake(), .fake(), .fake()] + + // when + sut.viewDidLoad() + + // then + wait(self.viewModelMock.model.numberOfProducts == 3) + + XCTAssertEqual(viewModelFactoryMock.invokedMakeParameters?.products, [autoRenewableProduct]) + + let ids = try XCTUnwrap(iapMock.invokedFetchParameters?.productIDs as? [String]) + XCTAssertEqual(ids, Array.ids) + } + + func test_thatPresenterReturnsProduct() { + // given + let autoRenewableProduct = StoreProduct.fake(productType: .autoRenewableSubscription) + + iapMock.stubbedInvokedFetch = [ + autoRenewableProduct, + .fake(productType: .consumable), + .fake(productType: .nonConsumable), + .fake(productType: .nonRenewableSubscription), + ] + viewModelFactoryMock.stubbedMake = [.fake(), .fake(), .fake()] + + // when + sut.viewDidLoad() + + // then + wait(self.sut.product(withID: autoRenewableProduct.productIdentifier) == autoRenewableProduct) + } + + func test_thatPresenterSubscribesToAProduct() async throws { + // given + let autoRenewableProduct = StoreProduct.fake(productType: .autoRenewableSubscription) + let fakeTransaction = StoreTransaction.fake() + + iapMock.stubbedPurchase = fakeTransaction + + iapMock.stubbedInvokedFetch = [ + autoRenewableProduct, + .fake(productType: .consumable), + .fake(productType: .nonConsumable), + .fake(productType: .nonRenewableSubscription), + ] + viewModelFactoryMock.stubbedMake = [.fake(), .fake(), .fake()] + + // when + sut.viewDidLoad() + sut.selectProduct(with: autoRenewableProduct.productIdentifier) + + wait(self.viewModelMock.model.selectedProductID != nil) + + let transaction = try await self.sut.subscribe(optionsHandler: nil) + + // then + XCTAssertEqual(transaction, fakeTransaction) + } +} + +// MARK: - Extensions + +private extension Array where Element == String { + static let ids: [String] = ["subscription"] +} diff --git a/Tests/SnapshotTests/Helpers/SnapshotTestCase.swift b/Tests/SnapshotTests/Helpers/SnapshotTestCase.swift new file mode 100644 index 000000000..0225fa739 --- /dev/null +++ b/Tests/SnapshotTests/Helpers/SnapshotTestCase.swift @@ -0,0 +1,102 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SnapshotTesting +import SwiftUI +import XCTest + +#if canImport(UIKit) + import UIKit +#elseif canImport(Cocoa) + import Cocoa +#endif + +// MARK: - SnapshotTestCase + +@available(watchOS, unavailable) +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +class SnapshotTestCase: XCTestCase { + // MARK: Properties + + private var osName: String { + #if os(iOS) + return "iOS" + #elseif os(macOS) + return "macOS" + #elseif os(tvOS) + return "tvOS" + #else + return "unknown" + #endif + } + + // MARK: Tests + + func assertSnapshots( + of view: some View, + size: CGSize, + userInterfaceStyle: UserInterfaceStyle = .light, + file: StaticString = #file, + testName: String = #function, + line: UInt = #line + ) { + #if os(iOS) || os(tvOS) + SnapshotTesting.assertSnapshots( + of: view, + as: [ + .image( + layout: .fixed(width: size.width, height: size.height), + traits: UITraitCollection(userInterfaceStyle: userInterfaceStyle.userInterfaceStyle) + ), + ], + file: file, + testName: testName + osName, + line: line + ) + #elseif os(macOS) + SnapshotTesting.assertSnapshots( + of: ThemableView(rootView: view, appearance: userInterfaceStyle.appearance), + as: [.image(precision: 1.0, size: size)], + file: file, + testName: testName + osName, + line: line + ) + #endif + } + + enum UserInterfaceStyle { + case light, dark + + #if os(iOS) || os(tvOS) + var userInterfaceStyle: UIUserInterfaceStyle { + switch self { + case .light: + return .light + case .dark: + return .dark + } + } + + #elseif os(macOS) + var appearance: NSAppearance? { + switch self { + case .light: + return .init(named: .vibrantLight) + case .dark: + return .init(named: .darkAqua) + } + } + #endif + + var colorScheme: ColorScheme { + switch self { + case .light: + return .light + case .dark: + return .dark + } + } + } +} diff --git a/Tests/SnapshotTests/Helpers/ThemableView.swift b/Tests/SnapshotTests/Helpers/ThemableView.swift new file mode 100644 index 000000000..14c374083 --- /dev/null +++ b/Tests/SnapshotTests/Helpers/ThemableView.swift @@ -0,0 +1,24 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +#if os(macOS) + import SwiftUI + + final class ThemableView: NSHostingView { + required init(rootView: Content, appearance: NSAppearance?) { + super.init(rootView: rootView) + self.appearance = appearance + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @MainActor required init(rootView _: Content) { + fatalError("init(rootView:) has not been implemented") + } + } +#endif diff --git a/Tests/SnapshotTests/ProductInfoViewSnapshotTests.swift b/Tests/SnapshotTests/ProductInfoViewSnapshotTests.swift new file mode 100644 index 000000000..50d3a4c99 --- /dev/null +++ b/Tests/SnapshotTests/ProductInfoViewSnapshotTests.swift @@ -0,0 +1,81 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import FlareUI +import SwiftUI +import XCTest + +// MARK: - ProductInfoViewSnapshotTests + +@available(watchOS, unavailable) +final class ProductInfoViewSnapshotTests: SnapshotTestCase { + func test_productInfoView_compactStyle_whenIconIsNil() { + assertSnapshots( + of: ProductInfoView( + viewModel: .viewModel, + icon: nil, + style: .compact, + action: {} + ), + size: .size + ) + } + + @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 12.0, *) + func test_productInfoView_compactStyle_whenIconIsNotNil() { + assertSnapshots( + of: ProductInfoView( + viewModel: .viewModel, + icon: .init(content: Image(systemName: "crown")), + style: .compact, + action: {} + ), + size: .size + ) + } + + #if os(iOS) + func test_productInfoView_largeStyle_whenIconIsNil() { + assertSnapshots( + of: ProductInfoView( + viewModel: .viewModel, + icon: nil, + style: .large, + action: {} + ), + size: .largeSize + ) + } + + func test_productInfoView_largeStyle_whenIconIsNotNil() { + assertSnapshots( + of: ProductInfoView( + viewModel: .viewModel, + icon: .init(content: Image(systemName: "crown")), + style: .large, + action: {} + ), + size: .largeSize + ) + } + #endif +} + +// MARK: - Constants + +private extension CGSize { + static let size = value(default: CGSize(width: 375.0, height: 76.0), tvOS: CGSize(width: 1920, height: 1080)) + static let largeSize = value(default: CGSize(width: 375.0, height: 400.0), tvOS: CGSize(width: 1920, height: 1080)) +} + +private extension ProductInfoView.ViewModel { + static let viewModel = ProductInfoView.ViewModel( + id: UUID().uuidString, + title: "My App Lifetime", + description: "Lifetime access to additional content", + price: "$19.99", + priceDescription: nil + ) +} diff --git a/Tests/SnapshotTests/ProductPlaceholderViewSnapshotTests.swift b/Tests/SnapshotTests/ProductPlaceholderViewSnapshotTests.swift new file mode 100644 index 000000000..3e93bf51c --- /dev/null +++ b/Tests/SnapshotTests/ProductPlaceholderViewSnapshotTests.swift @@ -0,0 +1,49 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import FlareUI +import Foundation + +// MARK: - ProductPlaceholderViewSnapshotTests + +@available(watchOS, unavailable) +final class ProductPlaceholderViewSnapshotTests: SnapshotTestCase { + func test_productPlaceholderView_compactStyle_whenIconIsHidden() { + assertSnapshots( + of: ProductPlaceholderView(isIconHidden: true, style: .compact), + size: .size + ) + } + + func test_productPlaceholderView_compactStyle_whenIconIsVisible() { + assertSnapshots( + of: ProductPlaceholderView(isIconHidden: false, style: .compact), + size: .size + ) + } + + #if os(iOS) + func test_productPlaceholderView_largeStyle_whenIconIsHidden() { + assertSnapshots( + of: ProductPlaceholderView(isIconHidden: true, style: .large), + size: .largeSize + ) + } + + func test_productPlaceholderView_largeStyle_whenIconIsVisible() { + assertSnapshots( + of: ProductPlaceholderView(isIconHidden: false, style: .large), + size: .largeSize + ) + } + #endif +} + +// MARK: - Constants + +private extension CGSize { + static let size = value(default: CGSize(width: 375.0, height: 76.0), tvOS: CGSize(width: 1920, height: 1080)) + static let largeSize = value(default: CGSize(width: 375.0, height: 400.0), tvOS: CGSize(width: 1920, height: 1080)) +} diff --git a/Tests/SnapshotTests/ProductViewSnapshotTests.swift b/Tests/SnapshotTests/ProductViewSnapshotTests.swift new file mode 100644 index 000000000..3500ab8f2 --- /dev/null +++ b/Tests/SnapshotTests/ProductViewSnapshotTests.swift @@ -0,0 +1,82 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import FlareMock +@testable import FlareUI +import FlareUIMock +import SwiftUI +import XCTest + +// MARK: - ProductViewSnapshotTests + +@available(watchOS, unavailable) +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +final class ProductViewSnapshotTests: SnapshotTestCase { + func test_productView_loading() { + assertSnapshots( + of: ProductWrapperView( + viewModel: .init(state: .loading, presenter: ProductPresenterMock()) + ), + size: .size + ) + } + + func test_productView_product() { + assertSnapshots( + of: ProductWrapperView( + viewModel: .init(state: .product(.fake()), presenter: ProductPresenterMock()) + ), + size: .size + ) + } + + func test_productView_error() { + assertSnapshots( + of: ProductWrapperView( + viewModel: .init(state: .error(.unknown), presenter: ProductPresenterMock()) + ), + size: .size + ) + } + + func test_productView_customStyle_product() { + assertSnapshots( + of: ProductWrapperView( + viewModel: .init(state: .product(.fake()), presenter: ProductPresenterMock()) + ).productViewStyle(CustomProductStyle()), + size: .size + ) + } +} + +// MARK: ProductViewSnapshotTests.CustomProductStyle + +@available(watchOS, unavailable) +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +private extension ProductViewSnapshotTests { + struct CustomProductStyle: IProductStyle { + @ViewBuilder + func makeBody(configuration: ProductStyleConfiguration) -> some View { + switch configuration.state { + case .loading: + Text("Loading") + case let .product(item): + VStack { + item.localizedPriceString.map { Text($0.debugDescription) } + Text(item.localizedTitle) + Text(item.localizedDescription) + } + case let .error(error): + Text(error.localizedDescription) + } + } + } +} + +// MARK: - Constants + +private extension CGSize { + static let size = value(default: CGSize(width: 375.0, height: 812.0), tvOS: CGSize(width: 1920, height: 1080)) +} diff --git a/Tests/SnapshotTests/ProductsViewSnapshotTests.swift b/Tests/SnapshotTests/ProductsViewSnapshotTests.swift new file mode 100644 index 000000000..e50509bbd --- /dev/null +++ b/Tests/SnapshotTests/ProductsViewSnapshotTests.swift @@ -0,0 +1,86 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import FlareMock +@testable import FlareUI +import FlareUIMock +import Foundation + +// MARK: - ProductsViewSnapshotTests + +@available(watchOS, unavailable) +@available(iOS 13.0, macOS 10.15, tvOS 13.0, *) +final class ProductsViewSnapshotTests: SnapshotTestCase { + func test_productsView_error() { + assertSnapshots( + of: ProductsWrapperView( + viewModel: ProductsViewModel( + state: .error(.storeProductNotAvailable), + presenter: ProductsPresenterMock() + ) + ), + size: .size + ) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_productsView_products_withRestoreButtons() { + let iapMock = FlareMock() + iapMock.stubbedInvokedFetch = [.fake(), .fake(), .fake()] + + assertSnapshots( + of: ProductsWrapperView( + viewModel: ProductsViewModel( + state: .products(iapMock.stubbedInvokedFetch), + presenter: ProductsPresenterMock() + ) + ) + .environment(\.productViewAssembly, ProductViewAssembly(iap: iapMock)) + .environment( + \.storeButtonsAssembly, + StoreButtonsAssembly( + storeButtonAssembly: StoreButtonAssembly(iap: FlareMock()), + policiesButtonAssembly: PoliciesButtonAssembly() + ) + ) + .storeButton(.visible, types: .restore, .restore), + size: .size + ) + } + + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + func test_productsView_products() { + let iapMock = FlareMock() + iapMock.stubbedInvokedFetch = [.fake(), .fake(), .fake()] + + assertSnapshots( + of: ProductsWrapperView( + viewModel: ProductsViewModel( + state: .products(iapMock.stubbedInvokedFetch), + presenter: ProductsPresenterMock() + ) + ) + .environment(\.productViewAssembly, ProductViewAssembly(iap: iapMock)) + .environment( + \.storeButtonsAssembly, + StoreButtonsAssembly( + storeButtonAssembly: StoreButtonAssembly(iap: FlareMock()), + policiesButtonAssembly: PoliciesButtonAssembly() + ) + ), + size: .size + ) + } +} + +// MARK: - Constants + +private extension CGSize { + static let size = value( + default: CGSize(width: 375.0, height: 812.0), + tvOS: CGSize(width: 1920, height: 1080), + macOS: CGSize(width: 1920, height: 1080) + ) +} diff --git a/Tests/SnapshotTests/SubscriptionsViewSnapshotTests.swift b/Tests/SnapshotTests/SubscriptionsViewSnapshotTests.swift new file mode 100644 index 000000000..512f07f3d --- /dev/null +++ b/Tests/SnapshotTests/SubscriptionsViewSnapshotTests.swift @@ -0,0 +1,70 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +@testable import FlareUI +import FlareUIMock +import SwiftUI +import XCTest + +// MARK: - SubscriptionsViewSnapshotTests + +@available(watchOS, unavailable) +final class SubscriptionsViewSnapshotTests: SnapshotTestCase { + // MARK: Properties + + // MARK: Tests + + func test_subscriptionsView_defaultStyle() { + assertSnapshots( + of: makeView(), + size: .size + ) + } + + func test_subscriptionsView_customStyle() { + assertSnapshots( + of: makeView() + .subscriptionMarketingContent(view: { Text("Header View") }) + #if os(iOS) + .subscriptionBackground(Color.gray) + .subscriptionHeaderContentBackground(Color.blue) + .subscriptionButtonLabel(.multiline) + #endif + .storeButton(.visible, types: .policies) + .tintColor(.green) + .subscriptionControlStyle(.button), + + size: .size + ) + } + + // MARK: Private + + private func makeView() -> SubscriptionsWrapperView { + SubscriptionsWrapperView( + viewModel: SubscriptionsViewModel( + state: .products( + [ + .init(id: "1", title: "Subscription", price: "5,99 $", description: "Description", isActive: true), + .init(id: "2", title: "Subscription", price: "5,99 $", description: "Description", isActive: false), + .init(id: "3", title: "Subscription", price: "5,99 $", description: "Description", isActive: false), + ] + ), + selectedProductID: "1", + presenter: SubscriptionsPresenterMock() + ) + ) + } +} + +// MARK: - Constants + +private extension CGSize { + static let size = value( + default: CGSize(width: 375.0, height: 812.0), + tvOS: CGSize(width: 1920, height: 1080), + macOS: CGSize(width: 1920, height: 1080) + ) +} diff --git a/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNil-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNil-iOS.1.png new file mode 100644 index 000000000..47ae24db1 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNil-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNil-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNil-macOS.1.png new file mode 100644 index 000000000..8d764c4de Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNil-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNil-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNil-tvOS.1.png new file mode 100644 index 000000000..3453072cd Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNil-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNotNil-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNotNil-iOS.1.png new file mode 100644 index 000000000..730943384 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNotNil-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNotNil-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNotNil-macOS.1.png new file mode 100644 index 000000000..6c02a5802 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNotNil-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNotNil-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNotNil-tvOS.1.png new file mode 100644 index 000000000..9df73135c Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_compactStyle_whenIconIsNotNil-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_largeStyle_whenIconIsNil-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_largeStyle_whenIconIsNil-iOS.1.png new file mode 100644 index 000000000..d68f4da9c Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_largeStyle_whenIconIsNil-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_largeStyle_whenIconIsNotNil-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_largeStyle_whenIconIsNotNil-iOS.1.png new file mode 100644 index 000000000..e851f561d Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductInfoViewSnapshotTests/test_productInfoView_largeStyle_whenIconIsNotNil-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsHidden-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsHidden-iOS.1.png new file mode 100644 index 000000000..86b892897 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsHidden-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsHidden-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsHidden-macOS.1.png new file mode 100644 index 000000000..c8d432501 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsHidden-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsHidden-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsHidden-tvOS.1.png new file mode 100644 index 000000000..6b85ea1cf Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsHidden-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsVisible-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsVisible-iOS.1.png new file mode 100644 index 000000000..46a969a4c Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsVisible-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsVisible-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsVisible-macOS.1.png new file mode 100644 index 000000000..2c13dfa9b Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsVisible-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsVisible-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsVisible-tvOS.1.png new file mode 100644 index 000000000..f2d9c1dd0 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_compactStyle_whenIconIsVisible-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_largeStyle_whenIconIsHidden-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_largeStyle_whenIconIsHidden-iOS.1.png new file mode 100644 index 000000000..b620fc209 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_largeStyle_whenIconIsHidden-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_largeStyle_whenIconIsVisible-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_largeStyle_whenIconIsVisible-iOS.1.png new file mode 100644 index 000000000..d24c95375 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_largeStyle_whenIconIsVisible-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_whenIconIsHidden-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_whenIconIsHidden-macOS.1.png new file mode 100644 index 000000000..c8d432501 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_whenIconIsHidden-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_whenIconIsHidden-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_whenIconIsHidden-tvOS.1.png new file mode 100644 index 000000000..6b85ea1cf Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_whenIconIsHidden-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_whenIconIsVisible-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_whenIconIsVisible-macOS.1.png new file mode 100644 index 000000000..58ef9e5d6 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_whenIconIsVisible-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_whenIconIsVisible-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_whenIconIsVisible-tvOS.1.png new file mode 100644 index 000000000..f2d9c1dd0 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductPlaceholderViewSnapshotTests/test_productPlaceholderView_whenIconIsVisible-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_customStyle_product-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_customStyle_product-iOS.1.png new file mode 100644 index 000000000..adeaeede1 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_customStyle_product-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_customStyle_product-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_customStyle_product-macOS.1.png new file mode 100644 index 000000000..75b461175 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_customStyle_product-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_customStyle_product-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_customStyle_product-tvOS.1.png new file mode 100644 index 000000000..83feff2eb Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_customStyle_product-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_error-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_error-iOS.1.png new file mode 100644 index 000000000..8f5cd0288 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_error-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_error-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_error-macOS.1.png new file mode 100644 index 000000000..d50139081 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_error-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_error-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_error-tvOS.1.png new file mode 100644 index 000000000..6b85ea1cf Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_error-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_loading-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_loading-iOS.1.png new file mode 100644 index 000000000..8f5cd0288 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_loading-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_loading-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_loading-macOS.1.png new file mode 100644 index 000000000..d50139081 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_loading-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_loading-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_loading-tvOS.1.png new file mode 100644 index 000000000..6b85ea1cf Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_loading-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_product-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_product-iOS.1.png new file mode 100644 index 000000000..764fe91af Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_product-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_product-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_product-macOS.1.png new file mode 100644 index 000000000..1678fefab Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_product-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_product-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_product-tvOS.1.png new file mode 100644 index 000000000..348fefce7 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductViewSnapshotTests/test_productView_product-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_error-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_error-iOS.1.png new file mode 100644 index 000000000..6b66ac4b1 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_error-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_error-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_error-macOS.1.png new file mode 100644 index 000000000..42c1a51f2 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_error-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_error-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_error-tvOS.1.png new file mode 100644 index 000000000..c45dfd980 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_error-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products-iOS.1.png new file mode 100644 index 000000000..600f56048 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products-macOS.1.png new file mode 100644 index 000000000..52be0f21b Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products-tvOS.1.png new file mode 100644 index 000000000..0ffe92223 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products_withRestoreButtons-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products_withRestoreButtons-iOS.1.png new file mode 100644 index 000000000..ad6e29206 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products_withRestoreButtons-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products_withRestoreButtons-macOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products_withRestoreButtons-macOS.1.png new file mode 100644 index 000000000..3ab211a11 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products_withRestoreButtons-macOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products_withRestoreButtons-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products_withRestoreButtons-tvOS.1.png new file mode 100644 index 000000000..2e869505b Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/ProductsViewSnapshotTests/test_productsView_products_withRestoreButtons-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/SubscriptionsViewSnapshotTests/test_subscriptionsView_customStyle-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/SubscriptionsViewSnapshotTests/test_subscriptionsView_customStyle-iOS.1.png new file mode 100644 index 000000000..09e4c36e0 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/SubscriptionsViewSnapshotTests/test_subscriptionsView_customStyle-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/SubscriptionsViewSnapshotTests/test_subscriptionsView_customStyle-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/SubscriptionsViewSnapshotTests/test_subscriptionsView_customStyle-tvOS.1.png new file mode 100644 index 000000000..0a887e686 Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/SubscriptionsViewSnapshotTests/test_subscriptionsView_customStyle-tvOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/SubscriptionsViewSnapshotTests/test_subscriptionsView_defaultStyle-iOS.1.png b/Tests/SnapshotTests/__Snapshots__/SubscriptionsViewSnapshotTests/test_subscriptionsView_defaultStyle-iOS.1.png new file mode 100644 index 000000000..0051c7a3f Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/SubscriptionsViewSnapshotTests/test_subscriptionsView_defaultStyle-iOS.1.png differ diff --git a/Tests/SnapshotTests/__Snapshots__/SubscriptionsViewSnapshotTests/test_subscriptionsView_defaultStyle-tvOS.1.png b/Tests/SnapshotTests/__Snapshots__/SubscriptionsViewSnapshotTests/test_subscriptionsView_defaultStyle-tvOS.1.png new file mode 100644 index 000000000..1c61e0b4b Binary files /dev/null and b/Tests/SnapshotTests/__Snapshots__/SubscriptionsViewSnapshotTests/test_subscriptionsView_defaultStyle-tvOS.1.png differ diff --git a/Tests/TestPlans/FlareUIUnitTests.xctestplan b/Tests/TestPlans/FlareUIUnitTests.xctestplan new file mode 100644 index 000000000..ceeb2656d --- /dev/null +++ b/Tests/TestPlans/FlareUIUnitTests.xctestplan @@ -0,0 +1,44 @@ +{ + "configurations" : [ + { + "id" : "982AD05B-EBD5-4A98-A373-D1868847261D", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "8D5BAE0D59CA24F5E8E0C695", + "name" : "FlareUI" + } + ] + }, + "targetForVariableExpansion" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "8D5BAE0D59CA24F5E8E0C695", + "name" : "FlareUI" + } + }, + "testTargets" : [ + { + "skippedTests" : [ + "ProductInfoViewSnapshotTests", + "ProductPlaceholderViewSnapshotTests", + "ProductViewSnapshotTests", + "ProductsViewSnapshotTests", + "SnapshotTestCase" + ], + "target" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "514A62DAD52F32058E8084C4", + "name" : "FlareUITests" + } + } + ], + "version" : 1 +} diff --git a/Tests/TestPlans/SnapshotTests.xctestplan b/Tests/TestPlans/SnapshotTests.xctestplan new file mode 100644 index 000000000..be288b860 --- /dev/null +++ b/Tests/TestPlans/SnapshotTests.xctestplan @@ -0,0 +1,37 @@ +{ + "configurations" : [ + { + "id" : "982AD05B-EBD5-4A98-A373-D1868847261D", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "8D5BAE0D59CA24F5E8E0C695", + "name" : "FlareUI" + } + ] + }, + "targetForVariableExpansion" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "8D5BAE0D59CA24F5E8E0C695", + "name" : "FlareUI" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:Flare.xcodeproj", + "identifier" : "12A0F956FEAD55CFA5B3AF45", + "name" : "FlareUISnapshotTests" + } + } + ], + "version" : 1 +} diff --git a/project.yml b/project.yml index 9f43f179d..3492d6748 100644 --- a/project.yml +++ b/project.yml @@ -17,6 +17,9 @@ packages: Atomic: url: https://github.com/space-code/atomic.git from: 1.0.0 + SnapshotTesting: + url: https://github.com/pointfreeco/swift-snapshot-testing.git + from: 1.15.3 targets: UnitTestHostApp: type: application @@ -47,7 +50,7 @@ targets: TARGETED_DEVICE_FAMILY: "1,2,3,4" SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" sources: - - path: Sources + - path: Sources/Flare scheme: testPlans: - path: Tests/TestPlans/AllTests.xctestplan @@ -57,6 +60,52 @@ targets: gatherCoverageData: true coverageTargets: - Flare + FlareUI: + type: framework + supportedDestinations: [iOS, tvOS, macOS] + dependencies: + - target: Flare + settings: + base: + GENERATE_INFOPLIST_FILE: YES + TARGETED_DEVICE_FAMILY: "1,2,3,4" + SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" + PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare.ui + sources: + - path: Sources/FlareUI + scheme: + testPlans: + - path: Tests/TestPlans/FlareUIUnitTests.xctestplan + defaultPlan: true + - path: Tests/TestPlans/SnapshotTests.xctestplan + gatherCoverageData: true + coverageTargets: + - FlareUI + FlareMock: + type: framework + supportedDestinations: [iOS, tvOS, macOS] + dependencies: + - target: Flare + settings: + base: + GENERATE_INFOPLIST_FILE: YES + TARGETED_DEVICE_FAMILY: "1,2,3,4" + SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" + sources: + - path: Sources/FlareMock + FlareUIMock: + type: framework + supportedDestinations: [iOS, tvOS, macOS] + dependencies: + - target: FlareUI + - target: FlareMock + settings: + base: + GENERATE_INFOPLIST_FILE: YES + TARGETED_DEVICE_FAMILY: "1,2,3,4" + SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" + sources: + - path: Sources/FlareUIMock FlareTests: type: bundle.unit-test supportedDestinations: [iOS, tvOS, macOS] @@ -64,6 +113,7 @@ targets: - package: Concurrency product: TestConcurrency - target: Flare + - target: FlareMock settings: base: GENERATE_INFOPLIST_FILE: YES @@ -72,6 +122,39 @@ targets: TARGETED_DEVICE_FAMILY: "1,2,3,4" sources: - Tests/FlareTests/UnitTests + FlareUITests: + type: bundle.unit-test + supportedDestinations: [iOS, tvOS, macOS] + dependencies: + - target: Flare + - target: FlareMock + - target: FlareUI + - target: FlareUIMock + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare-unit-tests + SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" + TARGETED_DEVICE_FAMILY: "1,2,3,4" + sources: + - Tests/FlareUITests + FlareUISnapshotTests: + type: bundle.unit-test + supportedDestinations: [iOS, tvOS, macOS] + dependencies: + - target: Flare + - target: FlareMock + - target: FlareUIMock + - target: FlareUI + - package: SnapshotTesting + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: com.spacecode.flare-snapshot-tests + SUPPORTED_PLATFORMS: "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator" + TARGETED_DEVICE_FAMILY: "1,2,3,4" + sources: + - Tests/SnapshotTests IntegrationTests: type: bundle.unit-test supportedDestinations: [iOS, tvOS, macOS]