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]