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/workflows/ci.yml b/.github/workflows/flare.yml similarity index 70% rename from .github/workflows/ci.yml rename to .github/workflows/flare.yml index 33acbd8d8..70e4f93fd 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 }} @@ -49,17 +41,18 @@ jobs: steps: - uses: actions/checkout@v3 - 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 }} @@ -81,16 +74,19 @@ jobs: runsOn: macos-13 steps: - uses: actions/checkout@v3 - - name: Install Dependencies - run: make setup_build_tools - - name: Generate project - run: make generate - 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 }} @@ -112,22 +108,19 @@ jobs: runsOn: macos-13 steps: - uses: actions/checkout@v3 - - name: Install Dependencies - run: make setup_build_tools - - name: Generate project - run: make generate - 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 }} @@ -153,22 +146,19 @@ jobs: runsOn: macos-13 steps: - uses: actions/checkout@v3 - - name: Install Dependencies - run: make setup_build_tools - - name: Generate project - run: make generate - 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 }} diff --git a/.github/workflows/flare_ui.yml b/.github/workflows/flare_ui.yml new file mode 100644 index 000000000..306d3012e --- /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@v3 + - 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@v3 + - 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@v3 + - 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@v3 + - 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@v3 + - 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@v3 + - 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@v2 + - 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@v3 + # - 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/swiftlint.yml b/.github/workflows/swiftlint.yml new file mode 100644 index 000000000..4d09696c0 --- /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@v3 + - 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..4f6c54e63 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 @@ -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 68c0d2b2d..81d4ebef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ## Added +- 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). 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..66e7786f8 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/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..d490e62d7 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/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..7174b9d90 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/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/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 579366f0d..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) } @@ -140,7 +140,7 @@ extension Flare: IFlare { await iapProvider.finish(transaction: transaction) } - public func addTransactionObserver(fallbackHandler: Closure>?) { + public func addTransactionObserver(fallbackHandler: Closure>?) { iapProvider.addTransactionObserver(fallbackHandler: fallbackHandler) } @@ -153,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/IFlare.swift b/Sources/Flare/Classes/IFlare.swift index 277b3d79d..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. /// @@ -135,7 +135,7 @@ public protocol IFlare { /// 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. @@ -151,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 a299804a6..306784694 100644 --- a/Sources/Flare/Classes/Models/StoreProduct.swift +++ b/Sources/Flare/Classes/Models/StoreProduct.swift @@ -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/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/IAPProvider/IAPProvider.swift b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift index 4ffac9238..fd787f988 100644 --- a/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift +++ b/Sources/Flare/Classes/Providers/IAPProvider/IAPProvider.swift @@ -60,14 +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))) } @@ -85,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) @@ -174,7 +178,7 @@ final class IAPProvider: IIAPProvider { } } - func addTransactionObserver(fallbackHandler: Closure>?) { + func addTransactionObserver(fallbackHandler: Closure>?) { purchaseProvider.addTransactionObserver(fallbackHandler: fallbackHandler) } @@ -188,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 100917471..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. /// @@ -134,7 +134,7 @@ public protocol IIAPProvider { /// 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. @@ -150,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 7fc89aae7..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 } @@ -90,7 +94,7 @@ final class PurchaseProvider { if let error = error as? IAPError { await completion(.failure(error)) } else { - await completion(.failure(IAPError.with(error: error))) + await completion(.failure(.with(error: error))) } } case let .failure(error): @@ -116,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)) } @@ -199,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)) } @@ -214,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/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..cf1438140 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Formatters/IDateComponentsFormatter.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - IDateComponentsFormatter + +protocol IDateComponentsFormatter { + var allowedUnits: NSCalendar.Unit { get set } + + 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..8bb30c808 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/Internal/PriceDisplayFormat.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - PriceDisplayFormat + +enum PriceDisplayFormat { + case short + 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..aadb4162b --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/Internal/ProductStyle.swift @@ -0,0 +1,11 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +enum ProductStyle { + case compact + 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..134c4b38e --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/PurchaseOptions.swift @@ -0,0 +1,24 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import StoreKit + +struct PurchaseOptions { + // MARK: Properties + + private var _options: Any? + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + var options: Set? { + _options as? Set + } + + // MARK: Initialization + + @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/SubscptionType.swift b/Sources/FlareUI/Classes/Core/Models/SubscptionType.swift new file mode 100644 index 000000000..765ea9a4b --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/SubscptionType.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// An enum represents a subscription type. +public enum SubscptionType { + /// Represents a subscription type identified by a group ID. + case groupID(id: String) + + /// Represents a subscription type for multiple subscriptions identified by their IDs. + case subscriptions(ids: Set) +} diff --git a/Sources/FlareUI/Classes/Core/Models/SubscriptionStatusVerifierType.swift b/Sources/FlareUI/Classes/Core/Models/SubscriptionStatusVerifierType.swift new file mode 100644 index 000000000..6d28a56dc --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/SubscriptionStatusVerifierType.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public enum SubscriptionStatusVerifierType { + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + case automatic + + 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..e337b7812 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/SubscriptionStoreButtonLabel.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public enum SubscriptionStoreButtonLabel { + case multiline + case price + case action + 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..4ef02c808 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Models/UIConfiguration.swift @@ -0,0 +1,18 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public struct UIConfiguration { + // MARK: Properties + + public let subscriptionVerifier: SubscriptionStatusVerifierType + + // MARK: Initialization + + 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..e57afb7bd --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Providers/ConfigurationProvider/ConfigurationProvider.swift @@ -0,0 +1,32 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - ConfigurationProvider + +final class ConfigurationProvider { + // MARK: Properties + + 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..3ac77739e --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Providers/ConfigurationProvider/IConfigurationProvider.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +protocol IConfigurationProvider { + var subscriptionVerifierType: SubscriptionStatusVerifierType? { get } + + 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..6d7a67e78 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusProvider/ISubscriptionStatusVerifierProvider.swift @@ -0,0 +1,10 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +protocol ISubscriptionStatusVerifierProvider { + 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..866b5aee2 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusProvider/SubscriptionStatusVerifierProvider.swift @@ -0,0 +1,34 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - SubscriptionStatusVerifierProvider + +final class SubscriptionStatusVerifierProvider { + // MARK: Properties + + private let configurationProvider: IConfigurationProvider + private let subscriptionStatusVerifierResolver: ISubscriptionStatusVerifierTypeResolver + + // MARK: Initialization + + init( + configurationProvider: IConfigurationProvider, + subscriptionStatusVerifierResolver: ISubscriptionStatusVerifierTypeResolver + ) { + self.configurationProvider = configurationProvider + self.subscriptionStatusVerifierResolver = subscriptionStatusVerifierResolver + } +} + +// MARK: ISubscriptionStatusVerifierProvider + +extension SubscriptionStatusVerifierProvider: ISubscriptionStatusVerifierProvider { + var subscriptionStatusVerifier: (any 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..11fd1d171 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusVerifier/ISubscriptionStatusVerifier.swift @@ -0,0 +1,10 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +public protocol ISubscriptionStatusVerifier { + 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..c6ceed8f0 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Providers/SubscriptionStatusVerifier/SubscriptionStatusVerifier.swift @@ -0,0 +1,29 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +final class SubscriptionStatusVerifier: ISubscriptionStatusVerifier { + // MARK: IActiveSubscriptionProvider + + 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..4060a9b64 --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Resolvers/SubscriptionStatusVerifierTypeResolver/ISubscriptionStatusVerifierTypeResolver.swift @@ -0,0 +1,10 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +protocol ISubscriptionStatusVerifierTypeResolver { + 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..184b0952a --- /dev/null +++ b/Sources/FlareUI/Classes/Core/Resolvers/SubscriptionStatusVerifierTypeResolver/SubscriptionStatusVerifierTypeResolver.swift @@ -0,0 +1,22 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +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..f8238e0c9 --- /dev/null +++ b/Sources/FlareUI/Classes/DI/Dependencies/IFlareDependencies.swift @@ -0,0 +1,16 @@ +// +// 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 } + + var configurationProvider: IConfigurationProvider { get } + + 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..7931934ef --- /dev/null +++ b/Sources/FlareUI/Classes/FlareUI.swift @@ -0,0 +1,37 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public final class FlareUI: IFlareUI { + // MARK: Properties + + private let dependencies: IFlareDependencies + + 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 + + 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..4bc6240f0 --- /dev/null +++ b/Sources/FlareUI/Classes/IFlareUI.swift @@ -0,0 +1,8 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +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..adfbf4e70 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductViewController/ProductViewController.swift @@ -0,0 +1,79 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import StoreKit +import SwiftUI + +// MARK: - ProductViewController + +#if os(iOS) + @available(watchOS, 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 + + public var onInAppPurchaseCompletion: PurchaseCompletionHandler? { + didSet { + viewModel.onInAppPurchaseCompletion = onInAppPurchaseCompletion + } + } + + public var productStyle: any IProductStyle = CompactProductStyle() { + didSet { + viewModel.productStyle = AnyProductStyle(style: productStyle) + } + } + + // MARK: Initialization + + 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 + + public extension ProductViewController { + @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..673e2d3c0 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/ProductsViewController/ProductsViewController.swift @@ -0,0 +1,105 @@ +// +// 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) + @available(watchOS, 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 + + public var onInAppPurchaseCompletion: PurchaseCompletionHandler? { + didSet { + viewModel.onInAppPurchaseCompletion = onInAppPurchaseCompletion + } + } + + public var productStyle: any IProductStyle = CompactProductStyle() { + didSet { + viewModel.productStyle = AnyProductStyle(style: productStyle) + } + } + + // MARK: Initialization + + 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 + + public extension ProductsViewController { + 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) } + } + } + + @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/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..0a83af672 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Controllers/SubscriptionsViewController/SubscriptionsViewController.swift @@ -0,0 +1,193 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import StoreKit +import SwiftUI + +// MARK: - SubscriptionsViewController + +#if os(iOS) + @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 + + public var onInAppPurchaseCompletion: PurchaseCompletionHandler? { + didSet { + viewModel.onInAppPurchaseCompletion = onInAppPurchaseCompletion + } + } + + public var subscriptionControlStyle: any ISubscriptionControlStyle = AutomaticSubscriptionControlStyle() { + didSet { + viewModel.subscriptionControlStyle = AnySubscriptionControlStyle(style: subscriptionControlStyle) + } + } + + public var subscriptionBackgroundColor: ColorRepresentation = .clear { + didSet { + viewModel.subscriptionBackgroundColor = Color(subscriptionBackgroundColor) + } + } + + public var subscriptionViewTintColor: ColorRepresentation = .blue { + didSet { + viewModel.subscriptionViewTintColor = Color(subscriptionViewTintColor) + } + } + + public var subscriptionButtonLabelStyle: SubscriptionStoreButtonLabel = .action { + didSet { + viewModel.subscriptionButtonLabelStyle = subscriptionButtonLabelStyle + } + } + + public var subscriptionMarketingContnentView: ViewRepresentation? { + didSet { + guard let subscriptionMarketingContnentView else { + viewModel.marketingContent = nil + return + } + viewModel.marketingContent = SUIViewWrapper( + view: subscriptionMarketingContnentView + ) + .eraseToAnyView() + } + } + + #if os(iOS) || os(tvOS) + public var subscriptionHeaderContentBackground: ColorRepresentation = .clear { + didSet { + viewModel.subscriptionHeaderContentBackground = Color(subscriptionHeaderContentBackground) + } + } + #endif + + #if os(iOS) + public var subscriptionPrivacyPolicyURL: URL? { + didSet { + viewModel.subscriptionPrivacyPolicyURL = subscriptionPrivacyPolicyURL + } + } + + public var subscriptionTermsOfServiceURL: URL? { + didSet { + viewModel.subscriptionTermsOfServiceURL = subscriptionTermsOfServiceURL + } + } + #endif + + public var subscriptionPrivacyPolicyView: ViewRepresentation? { + didSet { + guard let subscriptionPrivacyPolicyView else { + self.viewModel.subscriptionPrivacyPolicyView = nil + return + } + viewModel.subscriptionPrivacyPolicyView = SUIViewWrapper( + view: subscriptionPrivacyPolicyView + ).eraseToAnyView() + } + } + + public var subscriptionTermsOfServiceView: ViewRepresentation? { + didSet { + guard let subscriptionTermsOfServiceView else { + self.viewModel.subscriptionTermsOfServiceView = nil + return + } + viewModel.subscriptionTermsOfServiceView = SUIViewWrapper( + view: subscriptionTermsOfServiceView + ).eraseToAnyView() + } + } + + // MARK: Initialization + + 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 + + @available(iOS 13.0, macOS 11.0, tvOS 13.0, *) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + public extension SubscriptionsViewController { + 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) } + } + } + + @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..cf6e26f95 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Core/Models/StoreButtonType.swift @@ -0,0 +1,15 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - StoreButtonType + +public enum StoreButtonType { + @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) + case restore + + 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..dea49919e --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Core/Models/StoreButtonVisibility.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - StoreButtonVisibility + +public enum StoreButtonVisibility { + case visible, 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..79d456161 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Factories/ISubscriptionPriceViewModelFactory.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +// MARK: - ISubscriptionPriceViewModelFactory + +protocol ISubscriptionPriceViewModelFactory { + func make(_ product: StoreProduct, format: PriceDisplayFormat) -> String + 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..dd3f50b89 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Factories/SubscriptionPriceViewModelFactory.swift @@ -0,0 +1,95 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +final class SubscriptionPriceViewModelFactory: ISubscriptionPriceViewModelFactory { + // MARK: Properties + + private var dateFormatter: IDateComponentsFormatter + private let subscriptionDateComponentsFactory: ISubscriptionDateComponentsFactory + + // MARK: Initialization + + 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..76614c629 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+ProductViewStyle.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + 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..d1dff2b07 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+PurchaseCompletion.swift @@ -0,0 +1,13 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import SwiftUI + +public extension View { + 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..1a466c563 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+PurchaseOption.swift @@ -0,0 +1,19 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import StoreKit +import SwiftUI + +extension View { + @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) ?? []) } + } + + 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..2a655f07c --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+StoreButton.swift @@ -0,0 +1,32 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + 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 + } + } + } + + 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..f7209fc08 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+StoreButtonViewFontWeight.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +extension View { + 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..7829d7966 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionBackground.swift @@ -0,0 +1,16 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + @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..a599affce --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionControlStyle.swift @@ -0,0 +1,23 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + @available(watchOS, unavailable) + func subscriptionControlStyle(_ style: some ISubscriptionControlStyle) -> some View { + environment(\.subscriptionControlStyle, prepareStyle(style)) + } + + // MARK: Private + + 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..4369535f0 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionHeaderContentBackground.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + @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..faeee81d7 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionMarketingContent.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + 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..7559685d8 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionPickerItemBackground.swift @@ -0,0 +1,16 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + @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..d71921599 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionPrivacyPolicyDestination.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + 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..bd6330abe --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionPrivacyPolicyURL.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + @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..2a0335ae8 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionStoreButtonLabel.swift @@ -0,0 +1,16 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + @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..69533b8e0 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionTermsOfServiceDestination.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + 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..218bfb1dc --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionTermsOfServiceURL.swift @@ -0,0 +1,17 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + @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..af6011907 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+SubscriptionViewTint.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + 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..3ae07d39f --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/SUI/View+TintColor.swift @@ -0,0 +1,12 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +public extension View { + 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..5d5d69d37 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Helpers/UIKit/ViewController+Child.swift @@ -0,0 +1,32 @@ +// +// 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.view.topAnchor), + controller.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + controller.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + ]) + } + } +#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..a0d506c25 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Configuration/SubscriptionStoreControlStyleConfiguration.swift @@ -0,0 +1,49 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +// swiftlint:disable:next type_name +public struct SubscriptionStoreControlStyleConfiguration { + // MARK: Types + + struct Label: View { + var body: AnyView + + init(_ view: Content) { + body = view.eraseToAnyView() + } + } + + struct Description: View { + var body: AnyView + + init(_ view: Content) { + body = view.eraseToAnyView() + } + } + + struct Price: View { + var body: AnyView + + init(_ view: Content) { + body = view.eraseToAnyView() + } + } + + // MARK: Properties + + let label: Label + let description: Description + let price: Price + let isSelected: Bool + let isActive: Bool + + let action: () -> Void + + 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..38ba52506 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Components/Styles/Subscription/Protocols/ISubscriptionControlStyle.swift @@ -0,0 +1,19 @@ +// +// 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. + /// + /// - 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/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..3ae498f0f --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductPresenter.swift @@ -0,0 +1,66 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation +import SwiftUI + +// MARK: - IProductPresenter + +protocol IProductPresenter { + func viewDidLoad() + func purchase(options: PurchaseOptions?) async throws -> StoreTransaction +} + +// MARK: - ProductPresenter + +final class ProductPresenter: IPresenter { + // MARK: Properties + + private let productFetcher: IProductFetcherStrategy + private let purchaseService: IProductPurchaseService + + weak var viewModel: WrapperViewModel? + + // MARK: Initialization + + 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..e50a65700 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductPurchaseService.swift @@ -0,0 +1,55 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +// MARK: - IProductPurchaseService + +protocol IProductPurchaseService { + func purchase(product: StoreProduct, options: PurchaseOptions?) async throws -> StoreTransaction +} + +// MARK: - ProductPurchaseService + +actor ProductPurchaseService: IProductPurchaseService { + // MARK: Types + + enum Error: Swift.Error { + case alreadyExecuted + } + + // MARK: Properties + + private var iap: IFlare + + private var isExecuted = false + + // MARK: Initialization + + 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..8e5927d63 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductView.swift @@ -0,0 +1,29 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(iOS 13.0, tvOS 13.0, macOS 10.15, *) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +public struct ProductView: View { + // MARK: Properties + + private let presentationAssembly = PresentationAssembly() + + private let id: String + + // MARK: Initialization + + 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..367be5dea --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewAssembly.swift @@ -0,0 +1,55 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import SwiftUI + +// MARK: - IProductViewAssembly + +protocol IProductViewAssembly { + func assemble(id: String) -> ViewWrapper + func assemble(storeProduct: StoreProduct) -> ViewWrapper +} + +// MARK: - ProductViewAssembly + +final class ProductViewAssembly: IProductViewAssembly { + // MARK: Properties + + private let iap: IFlare + + // MARK: Initialization + + 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..5b8a9c9fd --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewModel.swift @@ -0,0 +1,26 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - ProductViewModel + +struct ProductViewModel: IModel { + enum State: Equatable { + case loading + case product(StoreProduct) + case error(IAPError) + } + + let state: State + let presenter: IProductPresenter +} + +extension ProductViewModel { + 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..6ab6aab7f --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductView/ProductViewType.swift @@ -0,0 +1,11 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare + +enum ProductViewType { + case product(StoreProduct) + 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..c8c1c5390 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsPresenter.swift @@ -0,0 +1,46 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - IProductsPresenter + +protocol IProductsPresenter { + func viewDidLoad() +} + +// MARK: - ProductsPresenter + +final class ProductsPresenter: IPresenter { + // MARK: Properties + + private let ids: any Collection + private let iap: IFlare + + weak var viewModel: WrapperViewModel? + + // MARK: Initialization + + 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..176efab52 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsView.swift @@ -0,0 +1,27 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(watchOS, unavailable) +public struct ProductsView: View { + // MARK: Properties + + private let presentationAssembly = PresentationAssembly() + + private let ids: any Collection + + // MARK: Initialization + + 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..3d7b22d5e --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsViewAssembly.swift @@ -0,0 +1,52 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import SwiftUI + +// MARK: - IProductsViewAssembly + +protocol IProductsViewAssembly { + func assemble(ids: some Collection) -> AnyView +} + +// MARK: - ProductsViewAssembly + +final class ProductsViewAssembly: IProductsViewAssembly { + // MARK: Properties + + private let storeButtonsAssembly: IStoreButtonsAssembly + private let productAssembly: IProductViewAssembly + private let iap: IFlare + + // MARK: Initialization + + 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..0c787c79a --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/ProductsView/ProductsViewModel.swift @@ -0,0 +1,29 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - ProductsViewModel + +struct ProductsViewModel: IModel { + enum State: Equatable { + case loading(Int) + case products([StoreProduct]) + case error(IAPError) + } + + let state: State + let presenter: IProductsPresenter +} + +extension ProductsViewModel { + 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..c6550de38 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsPresenter.swift @@ -0,0 +1,99 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation +import StoreKit + +// MARK: - ISubscriptionsPresenter + +protocol ISubscriptionsPresenter { + func viewDidLoad() + func selectProduct(with id: String) + func product(withID id: String) -> StoreProduct? + func subscribe(optionsHandler: PurchaseOptionHandler?) async throws -> StoreTransaction +} + +// MARK: - SubscriptionsPresenter + +@available(watchOS, unavailable) +final class SubscriptionsPresenter: IPresenter { + // MARK: Properties + + private let iap: IFlare + private let ids: any Collection + private let viewModelFactory: ISubscriptionsViewModelViewFactory + + private var products: [StoreProduct] = [] + + weak var viewModel: WrapperViewModel? + + // MARK: Initialization + + 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..839d43955 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsView.swift @@ -0,0 +1,29 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +@available(iOS 13.0, tvOS 13.0, macOS 11.0, *) +@available(watchOS, unavailable) +@available(visionOS, unavailable) +public struct SubscriptionsView: View { + // MARK: Properties + + private let presentationAssembly = PresentationAssembly() + + private let ids: any Collection + + // MARK: Initialization + + 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..717f065a5 --- /dev/null +++ b/Sources/FlareUI/Classes/Presentation/Views/SubscriptionsView/SubscriptionsViewModel.swift @@ -0,0 +1,48 @@ +// +// Flare +// Copyright © 2024 Space Code. All rights reserved. +// + +import Flare +import Foundation + +// MARK: - SubscriptionsViewModel + +@available(watchOS, unavailable) +struct SubscriptionsViewModel: IModel { + enum State: Equatable { + case loading + case products([SubscriptionView.ViewModel]) + case error(IAPError) + } + + var numberOfProducts: Int { + if case let .products(array) = state { + return array.count + } + return .zero + } + + let state: State + let selectedProductID: String? + let presenter: ISubscriptionsPresenter +} + +@available(watchOS, unavailable) +extension SubscriptionsViewModel { + func setState(_ state: State) -> SubscriptionsViewModel { + SubscriptionsViewModel( + state: state, + selectedProductID: selectedProductID, + presenter: presenter + ) + } + + 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/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 945ece829..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) 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 ac9ed1a5f..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) @@ -131,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) @@ -148,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) @@ -156,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) @@ -173,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 c1f84b1f6..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) @@ -84,10 +84,10 @@ final class IAPProviderMock: IIAPProvider { 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, ()) @@ -104,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, ()) @@ -296,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 3941055b7..f58fd3121 100644 --- a/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift +++ b/Tests/FlareTests/UnitTests/TestHelpers/Mocks/PurchaseProviderMock.swift @@ -22,10 +22,10 @@ final class PurchaseProviderMock: IPurchaseProvider { 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, ()) @@ -80,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]