diff --git a/.editorconfig b/.editorconfig index 28612472b7..2500ae9f34 100644 --- a/.editorconfig +++ b/.editorconfig @@ -98,4 +98,7 @@ ij_kotlin_variable_annotation_wrap = off ij_kotlin_while_on_new_line = false ij_kotlin_wrap_elvis_expressions = 1 ij_kotlin_wrap_expression_body_functions = 1 -ij_kotlin_wrap_first_method_in_call_chain = false \ No newline at end of file +ij_kotlin_wrap_first_method_in_call_chain = false + +[*.yml] +indent_size = 2 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index 6e2360ef21..86ff548d59 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,7 @@ androidHyperskillApp/keys/debug.properties filter=git-crypt diff=git-crypt androidHyperskillApp/keys/release.properties filter=git-crypt diff=git-crypt androidHyperskillApp/fastlane/Appfile filter=git-crypt diff=git-crypt androidHyperskillApp/google-services.json filter=git-crypt diff=git-crypt +shared/src/androidMain/keys/revenuecat.properties filter=git-crypt diff=git-crypt buildsystem/certs/** filter=git-crypt diff=git-crypt shared/keys/** filter=git-crypt diff=git-crypt sentry.properties filter=git-crypt diff=git-crypt diff --git a/.github/actions/setup-android/action.yml b/.github/actions/setup-android/action.yml index 04193f520c..3335f61e58 100644 --- a/.github/actions/setup-android/action.yml +++ b/.github/actions/setup-android/action.yml @@ -62,21 +62,21 @@ runs: - name: Setup Ruby if: ${{ inputs.setup-ruby == 'true' }} - uses: ruby/setup-ruby@v1.150.0 + uses: ruby/setup-ruby@v1.171.0 with: - ruby-version: '3.1.0' + ruby-version: '3.3.0' bundler-cache: true working-directory: './androidHyperskillApp' - name: Setup Java JDK - uses: actions/setup-java@v3.10.0 + uses: actions/setup-java@v4.0.0 with: java-version: '17' distribution: 'temurin' # Cache Gradle dependencies - name: Setup Gradle Dependencies Cache - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.1 with: path: ~/.gradle/caches key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }} @@ -85,7 +85,7 @@ runs: # Cache Gradle Wrapper - name: Setup Gradle Wrapper Cache - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.1 with: path: ~/.gradle/wrapper key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle*properties') }} @@ -94,7 +94,7 @@ runs: # Cache Kotlin/Native compiler - name: Setup Kotlin/Native Compiler Cache - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.1 with: path: ~/.konan key: ${{ runner.os }}-kotlin-native-compiler-${{ hashFiles('gradle/libs.versions.toml') }} diff --git a/.github/actions/setup-ios/action.yml b/.github/actions/setup-ios/action.yml index 9cd7b9d42e..5466c48dd2 100644 --- a/.github/actions/setup-ios/action.yml +++ b/.github/actions/setup-ios/action.yml @@ -20,7 +20,7 @@ runs: - name: Setup Xcode version uses: maxim-lobanov/setup-xcode@v1.6.0 with: - xcode-version: '15.1' + xcode-version: '15.2' - name: Homebrew install git-crypt run: brew install git-crypt @@ -49,21 +49,21 @@ runs: shell: bash - name: Setup Ruby - uses: ruby/setup-ruby@v1.150.0 + uses: ruby/setup-ruby@v1.171.0 with: - ruby-version: '3.1.0' + ruby-version: '3.3.0' bundler-cache: true working-directory: './iosHyperskillApp' - name: Setup Java JDK - uses: actions/setup-java@v3.10.0 + uses: actions/setup-java@v4.0.0 with: java-version: '17' distribution: 'temurin' # Cache Gradle dependencies - name: Setup Gradle Dependencies Cache - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.1 with: path: ~/.gradle/caches key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts') }} @@ -72,7 +72,7 @@ runs: # Cache Gradle Wrapper - name: Setup Gradle Wrapper Cache - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.1 with: path: ~/.gradle/wrapper key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle*properties') }} @@ -81,7 +81,7 @@ runs: # Cache Kotlin/Native compiler - name: Setup Kotlin/Native Compiler Cache - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.1 with: path: ~/.konan key: ${{ runner.os }}-kotlin-native-compiler-${{ hashFiles('gradle/libs.versions.toml') }} @@ -100,7 +100,7 @@ runs: # Cache Pods dependencies - name: Cache Pods - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.1 id: cache-pods with: path: './iosHyperskillApp/Pods' @@ -111,7 +111,7 @@ runs: # Cache CocoaPods - name: Cache CocoaPods if: steps.cache-pods.outputs.cache-hit != 'true' - uses: actions/cache@v3.3.2 + uses: actions/cache@v4.0.1 with: path: | ~/.cocoapods diff --git a/.github/workflows/android_beta_deployment.yml b/.github/workflows/android_beta_deployment.yml index 1a95ffdaf1..64b02cbfb1 100644 --- a/.github/workflows/android_beta_deployment.yml +++ b/.github/workflows/android_beta_deployment.yml @@ -28,43 +28,14 @@ jobs: uses: actions/checkout@v4.1.1 - name: Gradle Wrapper Validation - uses: gradle/wrapper-validation-action@v1.1.0 + uses: gradle/wrapper-validation-action@v2.1.1 # Build and submit to the Firebase App Distribution firebase-deployment: name: Deploy to Firebase App Distribution needs: gradle-wrapper-validation - runs-on: ubuntu-22.04 - environment: android_production - timeout-minutes: 60 - - steps: - - name: Checkout - uses: actions/checkout@v4.1.1 - - - name: Setup CI - id: setup - uses: ./.github/actions/setup-android - with: - git-crypt-key: ${{ secrets.GIT_CRYPT_KEY }} - release-keystore-content: ${{ secrets.HYPERSKILL_RELEASE_KEYSTORE_CONTENT }} - setup-ruby: true - - - name: Submit a new Beta Build to Firebase App Distribution - working-directory: "./androidHyperskillApp" - run: | - bundle exec fastlane beta \ - firebase_app_id:"${{ secrets.FIREBASE_APP_ID }}" \ - firebase_cli_token:"${{ secrets.FIREBASE_TOKEN }}" - env: - HYPERSKILL_IS_INTERNAL_TESTING: true - HYPERSKILL_KEYSTORE_PATH: ${{ steps.setup.outputs.release-keystore-path }} - HYPERSKILL_RELEASE_STORE_PASSWORD: ${{ secrets.HYPERSKILL_RELEASE_STORE_PASSWORD }} - HYPERSKILL_RELEASE_KEY_ALIAS: ${{ secrets.HYPERSKILL_RELEASE_KEY_ALIAS }} - HYPERSKILL_RELEASE_KEY_PASSWORD: ${{ secrets.HYPERSKILL_RELEASE_KEY_PASSWORD }} - IS_GIT_CRYPT_UNLOCKED: ${{ steps.setup.outputs.is-git-crypt-unlocked }} - GITHUB_USER: ${{ github.actor }} - GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: ./.github/workflows/android_deploy_to_firebase.yml + secrets: inherit # Build and submit to the Google Play google-play-deployment: diff --git a/.github/workflows/android_deploy_to_firebase.yml b/.github/workflows/android_deploy_to_firebase.yml new file mode 100644 index 0000000000..408aafa1bf --- /dev/null +++ b/.github/workflows/android_deploy_to_firebase.yml @@ -0,0 +1,38 @@ +name: Deploy to Firebase App Distribution + +on: + workflow_call: + +jobs: + deploy: + runs-on: ubuntu-22.04 + environment: android_production + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + + - name: Setup CI + id: setup + uses: ./.github/actions/setup-android + with: + git-crypt-key: ${{ secrets.GIT_CRYPT_KEY }} + release-keystore-content: ${{ secrets.HYPERSKILL_RELEASE_KEYSTORE_CONTENT }} + setup-ruby: true + + - name: Submit a new Beta Build to Firebase App Distribution + working-directory: "./androidHyperskillApp" + run: | + bundle exec fastlane beta \ + firebase_app_id:"${{ secrets.FIREBASE_APP_ID }}" \ + firebase_cli_token:"${{ secrets.FIREBASE_TOKEN }}" + env: + HYPERSKILL_IS_INTERNAL_TESTING: true + HYPERSKILL_KEYSTORE_PATH: ${{ steps.setup.outputs.release-keystore-path }} + HYPERSKILL_RELEASE_STORE_PASSWORD: ${{ secrets.HYPERSKILL_RELEASE_STORE_PASSWORD }} + HYPERSKILL_RELEASE_KEY_ALIAS: ${{ secrets.HYPERSKILL_RELEASE_KEY_ALIAS }} + HYPERSKILL_RELEASE_KEY_PASSWORD: ${{ secrets.HYPERSKILL_RELEASE_KEY_PASSWORD }} + IS_GIT_CRYPT_UNLOCKED: ${{ steps.setup.outputs.is-git-crypt-unlocked }} + GITHUB_USER: ${{ github.actor }} + GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/android_deploy_to_firebase_manually.yml b/.github/workflows/android_deploy_to_firebase_manually.yml new file mode 100644 index 0000000000..4647b3305e --- /dev/null +++ b/.github/workflows/android_deploy_to_firebase_manually.yml @@ -0,0 +1,27 @@ +name: Android Deploy Manually to Firebase App Distribution + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + # Run Gradle Wrapper Validation Action to verify the wrapper's checksum + gradle-wrapper-validation: + name: Gradle Wrapper Validation + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + + - name: Gradle Wrapper Validation + uses: gradle/wrapper-validation-action@v2.1.1 + + # Build and submit to the Firebase App Distribution + firebase-deployment: + name: Deploy to Firebase App Distribution + needs: gradle-wrapper-validation + uses: ./.github/workflows/android_deploy_to_firebase.yml + secrets: inherit diff --git a/.github/workflows/android_release_deployment.yml b/.github/workflows/android_release_deployment.yml index ef148ce713..83e650a2e9 100644 --- a/.github/workflows/android_release_deployment.yml +++ b/.github/workflows/android_release_deployment.yml @@ -19,7 +19,7 @@ defaults: jobs: # Run Gradle Wrapper Validation Action to verify the wrapper's checksum gradle-wrapper-validation: - if: ${{ github.ref == 'refs/heads/main' }} + # if: ${{ github.ref == 'refs/heads/main' }} name: Gradle Wrapper Validation runs-on: ubuntu-22.04 steps: @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Gradle Wrapper Validation - uses: gradle/wrapper-validation-action@v1.1.0 + uses: gradle/wrapper-validation-action@v2.1.1 # Build and submit to the Google Play deployment: diff --git a/.github/workflows/auto_author_assign.yml b/.github/workflows/auto_author_assign.yml index fa63bf354a..5c082fbe96 100644 --- a/.github/workflows/auto_author_assign.yml +++ b/.github/workflows/auto_author_assign.yml @@ -12,6 +12,6 @@ jobs: runs-on: ubuntu-22.04 if: ${{ !github.event.pull_request.assignee }} steps: - - uses: toshimaru/auto-author-assign@v2.0.1 + - uses: toshimaru/auto-author-assign@v2.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/automerge_into_release.yml b/.github/workflows/automerge_into_release.yml index 3fdd0523f0..b63f9dfd0b 100644 --- a/.github/workflows/automerge_into_release.yml +++ b/.github/workflows/automerge_into_release.yml @@ -27,7 +27,7 @@ jobs: automerge: needs: files-changed name: Automerge - runs-on: macos-13 + runs-on: macos-14 steps: - name: Checkout @@ -37,9 +37,9 @@ jobs: token: ${{ secrets.GH_PAT }} - name: Setup Ruby - uses: ruby/setup-ruby@v1.150.0 + uses: ruby/setup-ruby@v1.171.0 with: - ruby-version: "3.1.0" + ruby-version: "3.3.0" bundler-cache: true working-directory: "./iosHyperskillApp" diff --git a/.github/workflows/build_caches.yml b/.github/workflows/build_caches.yml index a35c69db8f..fec50846a1 100644 --- a/.github/workflows/build_caches.yml +++ b/.github/workflows/build_caches.yml @@ -50,7 +50,7 @@ jobs: needs: files-changed if: ${{ github.event_name == 'workflow_dispatch' || needs.files-changed.outputs.ios == 'true' }} name: Build iOS Caches - runs-on: macos-13 + runs-on: macos-14 timeout-minutes: 60 steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edeec5903a..76aa95a27d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Gradle Wrapper Validation - uses: gradle/wrapper-validation-action@v1.1.0 + uses: gradle/wrapper-validation-action@v2.1.1 files-changed: name: Detect changes @@ -79,7 +79,7 @@ jobs: needs: files-changed if: ${{ needs.files-changed.outputs.ios == 'true' || needs.files-changed.outputs.shared == 'true' }} name: Run SwiftLint - runs-on: macos-13 + runs-on: macos-14 timeout-minutes: 10 steps: @@ -87,14 +87,14 @@ jobs: uses: actions/checkout@v4.1.1 - name: Setup Ruby - uses: ruby/setup-ruby@v1.150.0 + uses: ruby/setup-ruby@v1.171.0 with: - ruby-version: '3.1.0' + ruby-version: '3.3.0' bundler-cache: true working-directory: './iosHyperskillApp' - name: Cache Pods - uses: actions/cache@v3.3.3 + uses: actions/cache@v4.0.1 id: cache-pods with: path: './iosHyperskillApp/Pods' @@ -162,7 +162,7 @@ jobs: build-ios: needs: swiftlint name: Build iOS - runs-on: macos-13 + runs-on: macos-14 timeout-minutes: 30 steps: diff --git a/.github/workflows/detect_changed_files_reusable_workflow.yml b/.github/workflows/detect_changed_files_reusable_workflow.yml index d45f50695b..f88d1f64ab 100644 --- a/.github/workflows/detect_changed_files_reusable_workflow.yml +++ b/.github/workflows/detect_changed_files_reusable_workflow.yml @@ -55,7 +55,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Detect changes - uses: dorny/paths-filter@v2.11.1 + uses: dorny/paths-filter@v3.0.1 id: changes with: base: ${{ inputs.base }} diff --git a/.github/workflows/gh_pages_analytics.yml b/.github/workflows/gh_pages_analytics.yml index a40692bd3c..43da10c456 100644 --- a/.github/workflows/gh_pages_analytics.yml +++ b/.github/workflows/gh_pages_analytics.yml @@ -52,7 +52,7 @@ jobs: GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload Artifact - uses: actions/upload-pages-artifact@v3.0.0 + uses: actions/upload-pages-artifact@v3.0.1 with: name: 'github-pages-analytics' path: 'shared/build/dokka/analytics' @@ -69,6 +69,6 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4.0.2 + uses: actions/deploy-pages@v4.0.4 with: artifact_name: 'github-pages-analytics' \ No newline at end of file diff --git a/.github/workflows/ios_beta_deployment.yml b/.github/workflows/ios_beta_deployment.yml index dce27be79f..fdba903a12 100644 --- a/.github/workflows/ios_beta_deployment.yml +++ b/.github/workflows/ios_beta_deployment.yml @@ -28,13 +28,13 @@ jobs: uses: actions/checkout@v4.1.1 - name: Gradle Wrapper Validation - uses: gradle/wrapper-validation-action@v1.1.0 + uses: gradle/wrapper-validation-action@v2.1.1 # Build, archive for ad-hoc and submit to Firebase App Distribution deployment: name: iOS Beta Deployment needs: gradle-wrapper-validation - runs-on: macos-13 + runs-on: macos-14 environment: ios_production timeout-minutes: 60 diff --git a/.github/workflows/ios_release_deployment.yml b/.github/workflows/ios_release_deployment.yml index 11a1a61112..f2a707e490 100644 --- a/.github/workflows/ios_release_deployment.yml +++ b/.github/workflows/ios_release_deployment.yml @@ -24,13 +24,13 @@ jobs: uses: actions/checkout@v4.1.1 - name: Gradle Wrapper Validation - uses: gradle/wrapper-validation-action@v1.1.0 + uses: gradle/wrapper-validation-action@v2.1.1 # Build, archive for app-store and submit to App Store Connect deployment: name: iOS Release Deployment needs: gradle-wrapper-validation - runs-on: macos-13 + runs-on: macos-14 environment: ios_production timeout-minutes: 60 diff --git a/.github/workflows/ios_unit_testing.yml b/.github/workflows/ios_unit_testing.yml index d64c2d5bbe..5823a4e92e 100644 --- a/.github/workflows/ios_unit_testing.yml +++ b/.github/workflows/ios_unit_testing.yml @@ -18,7 +18,7 @@ jobs: test: if: ${{ vars.IS_IOS_UNIT_TESTING_ENABLED == 'true' }} name: Run iOS unit tests - runs-on: macos-13 + runs-on: macos-14 timeout-minutes: 60 steps: diff --git a/androidHyperskillApp/.ruby-version b/androidHyperskillApp/.ruby-version index fd2a01863f..15a2799817 100644 --- a/androidHyperskillApp/.ruby-version +++ b/androidHyperskillApp/.ruby-version @@ -1 +1 @@ -3.1.0 +3.3.0 diff --git a/androidHyperskillApp/Gemfile b/androidHyperskillApp/Gemfile index 6c85b6405a..3dda334cd0 100644 --- a/androidHyperskillApp/Gemfile +++ b/androidHyperskillApp/Gemfile @@ -1,5 +1,5 @@ source "https://rubygems.org" -ruby "3.1.0" +ruby "3.3.0" gem "fastlane", "2.219.0" diff --git a/androidHyperskillApp/Gemfile.lock b/androidHyperskillApp/Gemfile.lock index 3bcbb62c40..db879a5683 100644 --- a/androidHyperskillApp/Gemfile.lock +++ b/androidHyperskillApp/Gemfile.lock @@ -8,17 +8,17 @@ GEM artifactory (3.0.15) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.877.0) - aws-sdk-core (3.190.1) + aws-partitions (1.883.0) + aws-sdk-core (3.191.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.8) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.75.0) - aws-sdk-core (~> 3, >= 3.188.0) + aws-sdk-kms (1.77.0) + aws-sdk-core (~> 3, >= 3.191.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.142.0) - aws-sdk-core (~> 3, >= 3.189.0) + aws-sdk-s3 (1.143.0) + aws-sdk-core (~> 3, >= 3.191.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.8) aws-sigv4 (1.8.0) @@ -32,7 +32,7 @@ GEM declarative (0.0.20) digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) - domain_name (0.6.20231109) + domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) excon (0.109.0) @@ -106,13 +106,13 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) - fastlane-plugin-firebase_app_distribution (0.8.0) + fastlane-plugin-firebase_app_distribution (0.9.0) google-apis-firebaseappdistribution_v1 (~> 0.3.0) google-apis-firebaseappdistribution_v1alpha (~> 0.2.0) gh_inspector (1.1.3) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.2) + google-apis-core (0.11.3) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -120,7 +120,6 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.a) rexml - webrick google-apis-firebaseappdistribution_v1 (0.3.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-firebaseappdistribution_v1alpha (0.2.0) @@ -129,7 +128,7 @@ GEM google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.29.0) + google-apis-storage_v1 (0.31.0) google-apis-core (>= 0.11.0, < 2.a) google-cloud-core (1.6.1) google-cloud-env (>= 1.0, < 3.a) @@ -137,11 +136,11 @@ GEM google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) google-cloud-errors (1.3.1) - google-cloud-storage (1.45.0) + google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.29.0) + google-apis-storage_v1 (~> 0.31.0) google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) @@ -161,7 +160,7 @@ GEM mini_magick (4.12.0) mini_mime (1.1.5) multi_json (1.15.0) - multipart-post (2.3.0) + multipart-post (2.4.0) nanaimo (0.3.0) naturally (2.2.1) optparse (0.4.0) @@ -179,7 +178,7 @@ GEM ruby2_keywords (0.0.5) rubyzip (2.3.2) security (0.1.3) - signet (0.18.0) + signet (0.19.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) @@ -197,9 +196,8 @@ GEM tty-cursor (~> 0.7) uber (0.1.0) unicode-display_width (2.5.0) - webrick (1.8.1) word_wrap (1.0.0) - xcodeproj (1.23.0) + xcodeproj (1.24.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) @@ -213,7 +211,9 @@ GEM PLATFORMS arm64-darwin-22 + arm64-darwin-23 x86_64-darwin-19 + x86_64-darwin-20 x86_64-linux DEPENDENCIES @@ -221,7 +221,7 @@ DEPENDENCIES fastlane-plugin-firebase_app_distribution RUBY VERSION - ruby 3.1.0p0 + ruby 3.3.0p0 BUNDLED WITH - 2.4.4 + 2.5.5 diff --git a/androidHyperskillApp/build.gradle.kts b/androidHyperskillApp/build.gradle.kts index cbe0a886cc..ef699dce86 100644 --- a/androidHyperskillApp/build.gradle.kts +++ b/androidHyperskillApp/build.gradle.kts @@ -29,6 +29,8 @@ dependencies { implementation(libs.kotlin.coroutines.core) implementation(libs.kotlin.coroutines.android) + implementation(libs.kermit) + implementation(libs.kit.view.ui) implementation(libs.kit.view.injection) implementation(libs.kit.view.redux) @@ -41,6 +43,8 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) + implementation(libs.google.play.review) + implementation(libs.viewbinding) implementation(libs.kit.ui.adapters) diff --git a/androidHyperskillApp/src/main/AndroidManifest.xml b/androidHyperskillApp/src/main/AndroidManifest.xml index 3231dc6630..f05819b206 100644 --- a/androidHyperskillApp/src/main/AndroidManifest.xml +++ b/androidHyperskillApp/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + diff --git a/androidHyperskillApp/src/main/assets/scripts/remove_data_mobile_hidden_elements.js b/androidHyperskillApp/src/main/assets/scripts/remove_data_mobile_hidden_elements.js new file mode 100644 index 0000000000..494861697b --- /dev/null +++ b/androidHyperskillApp/src/main/assets/scripts/remove_data_mobile_hidden_elements.js @@ -0,0 +1,6 @@ +addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('[data-mobile-hidden="true"]') + .forEach(element => { + element.parentNode.removeChild(element) + }) +}); \ No newline at end of file diff --git a/androidHyperskillApp/src/main/assets/scripts/remove_iframes.js b/androidHyperskillApp/src/main/assets/scripts/remove_iframes.js new file mode 100644 index 0000000000..ce2e1906fd --- /dev/null +++ b/androidHyperskillApp/src/main/assets/scripts/remove_iframes.js @@ -0,0 +1,6 @@ +addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('iframe') + .forEach(element => + element.parentNode.removeChild(element) + ); +}); \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/HyperskillApp.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/HyperskillApp.kt index 0d9114e02f..80ccb87552 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/HyperskillApp.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/HyperskillApp.kt @@ -55,14 +55,14 @@ class HyperskillApp : Application(), ImageLoaderFactory { ) setNightMode(appGraph) - initSentry() + initSentry(appGraph) initChannels() } override fun newImageLoader(): ImageLoader = graph().imageLoadingComponent.imageLoader - private fun initSentry() { + private fun initSentry(appGraph: AppGraph) { appGraph.sentryComponent.sentryInteractor.setup() } diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/ContextExtensions.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/ContextExtensions.kt index 796234eee8..c1f6f0edce 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/ContextExtensions.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/ContextExtensions.kt @@ -1,7 +1,9 @@ package org.hyperskill.app.android.core.extensions +import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.net.Uri import android.widget.Toast @@ -22,4 +24,14 @@ fun Context.openUrl(uri: Uri) { fun Context.openUrl(url: String) { openUrl(Uri.parse(url)) -} \ No newline at end of file +} + +/** + * Find the closest Activity in a given Context. + */ +tailrec fun Context.findActivity(): Activity = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> throw IllegalStateException("Can't find Activity in a given context") + } \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/PaddingValuesExtensions.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/PaddingValuesExtensions.kt new file mode 100644 index 0000000000..dad7dced1d --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/PaddingValuesExtensions.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.android.core.extensions + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalLayoutDirection + +@Composable +operator fun PaddingValues.plus(other: PaddingValues): PaddingValues { + val layoutDirection = LocalLayoutDirection.current + return PaddingValues( + start = this.calculateStartPadding(layoutDirection) + + other.calculateStartPadding(layoutDirection), + top = this.calculateTopPadding() + other.calculateTopPadding(), + end = this.calculateEndPadding(layoutDirection) + + other.calculateEndPadding(layoutDirection), + bottom = this.calculateBottomPadding() + other.calculateBottomPadding(), + ) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/ThemeExtension.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/ThemeExtension.kt index 40ce6f5060..9f1df25be7 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/ThemeExtension.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/ThemeExtension.kt @@ -1,14 +1,14 @@ package org.hyperskill.app.android.core.extensions -import org.hyperskill.app.android.HyperskillApp +import android.content.Context import org.hyperskill.app.profile_settings.domain.model.Theme -val Theme.representation - get() = when (this) { +fun Theme.getStringRepresentation(context: Context): String = + when (this) { Theme.DARK -> - HyperskillApp.graph().context.resources.getString(org.hyperskill.app.R.string.settings_theme_dark) + context.resources.getString(org.hyperskill.app.R.string.settings_theme_dark) Theme.LIGHT -> - HyperskillApp.graph().context.resources.getString(org.hyperskill.app.R.string.settings_theme_light) + context.resources.getString(org.hyperskill.app.R.string.settings_theme_light) Theme.SYSTEM -> - HyperskillApp.graph().context.resources.getString(org.hyperskill.app.R.string.settings_theme_system) + context.resources.getString(org.hyperskill.app.R.string.settings_theme_system) } \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/compose/PaddingValuesExtensions.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/compose/PaddingValuesExtensions.kt new file mode 100644 index 0000000000..06391f8a80 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/extensions/compose/PaddingValuesExtensions.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.android.core.extensions.compose + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalLayoutDirection + +@Composable +operator fun PaddingValues.plus(other: PaddingValues): PaddingValues { + val layoutDirection = LocalLayoutDirection.current + return PaddingValues( + start = this.calculateStartPadding(layoutDirection) + + other.calculateStartPadding(layoutDirection), + top = this.calculateTopPadding() + other.calculateTopPadding(), + end = this.calculateEndPadding(layoutDirection) + + other.calculateEndPadding(layoutDirection), + bottom = this.calculateBottomPadding() + other.calculateBottomPadding(), + ) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/injection/AndroidAppComponentImpl.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/injection/AndroidAppComponentImpl.kt index 1f48ac1048..52d3ba9d39 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/injection/AndroidAppComponentImpl.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/injection/AndroidAppComponentImpl.kt @@ -1,7 +1,6 @@ package org.hyperskill.app.android.core.injection import android.app.Application -import android.content.Context import org.hyperskill.app.analytic.domain.model.AnalyticEngine import org.hyperskill.app.analytic.injection.AnalyticComponent import org.hyperskill.app.analytic.injection.AnalyticComponentImpl @@ -29,20 +28,18 @@ import org.hyperskill.app.sentry.injection.SentryComponent import org.hyperskill.app.sentry.injection.SentryComponentImpl class AndroidAppComponentImpl( - private val application: Application, + override val application: Application, userAgentInfo: UserAgentInfo, buildVariant: BuildVariant, analyticEngines: List = emptyList() ) : AndroidAppComponent, CommonAndroidAppGraphImpl() { - override val context: Context - get() = application override val commonComponent: CommonComponent by lazy { - CommonComponentImpl(application, buildVariant, userAgentInfo) + CommonComponentImpl(this.application, buildVariant, userAgentInfo) } override val imageLoadingComponent: ImageLoadingComponent by lazy { - ImageLoadingComponentImpl(context) + ImageLoadingComponentImpl(this.application) } override val navigationComponent: NavigationComponent by lazy { @@ -61,7 +58,7 @@ class AndroidAppComponentImpl( } override val platformLocalNotificationComponent: PlatformLocalNotificationComponent by lazy { - PlatformLocalNotificationComponentImpl(application, this) + PlatformLocalNotificationComponentImpl(this.application, this) } override fun buildPlatformPushNotificationsComponent(): AndroidPlatformPushNotificationComponent = @@ -80,11 +77,11 @@ class AndroidAppComponentImpl( * Latex component */ override fun buildPlatformLatexComponent(): PlatformLatexComponent = - PlatformLatexComponentImpl(application, networkComponent.endpointConfigInfo) + PlatformLatexComponentImpl(this.application, networkComponent.endpointConfigInfo) /** * CodeEditor component */ override fun buildPlatformCodeEditorComponent(): PlatformCodeEditorComponent = - PlatformCodeEditorComponentImpl(application) + PlatformCodeEditorComponentImpl(this.application) } \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillButton.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillButton.kt index 2675ee2929..d14510f12c 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillButton.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillButton.kt @@ -11,6 +11,7 @@ import androidx.compose.material.ButtonElevation import androidx.compose.material.MaterialTheme import androidx.compose.material.ProvideTextStyle import androidx.compose.material.TextButton +import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -38,10 +39,12 @@ object HyperskillButtonDefaults { @Composable fun buttonColors( - backgroundColor: Color = colorResource(id = R.color.button_primary) + backgroundColor: Color = colorResource(id = R.color.button_primary), + contentColor: Color = contentColorFor(backgroundColor) ): ButtonColors = ButtonDefaults.buttonColors( - backgroundColor = backgroundColor + backgroundColor = backgroundColor, + contentColor = contentColor ) @Composable @@ -76,6 +79,44 @@ fun HyperskillButton( ) } +@Composable +fun HyperskillOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + elevation: ButtonElevation? = null, + shape: Shape = MaterialTheme.shapes.small, + border: BorderStroke? = BorderStroke(1.dp, colorResource(id = R.color.button_tertiary)), + colors: ButtonColors = HyperskillButtonDefaults.buttonColors( + backgroundColor = Color.Transparent, + contentColor = colorResource(id = R.color.button_tertiary) + ), + contentPadding: PaddingValues = HyperskillButtonDefaults.ContentPadding, + content: @Composable RowScope.() -> Unit +) { + HyperskillButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + interactionSource = interactionSource, + elevation = elevation, + shape = shape, + border = border, + colors = colors, + contentPadding = contentPadding, + content = { + ProvideTextStyle( + value = MaterialTheme.typography.button.copy( + color = colorResource(id = R.color.button_tertiary) + ) + ) { + content() + } + } + ) +} + @Composable fun HyperskillTextButton( onClick: () -> Unit, diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillProgressIndicator.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillProgressIndicator.kt new file mode 100644 index 0000000000..5fcc32ec89 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillProgressIndicator.kt @@ -0,0 +1,16 @@ +package org.hyperskill.app.android.core.view.ui.widget.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import me.zhanghai.android.materialprogressbar.MaterialProgressBar + +@Composable +fun HyperskillProgressBar(modifier: Modifier = Modifier) { + AndroidView( + factory = { context -> + MaterialProgressBar(context) + }, + modifier = modifier + ) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillTopAppBar.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillTopAppBar.kt index 7b5a5dcc27..6a11d34f4b 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillTopAppBar.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/HyperskillTopAppBar.kt @@ -9,6 +9,7 @@ import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -20,6 +21,7 @@ fun HyperskillTopAppBar( title: String, modifier: Modifier = Modifier, onNavigationIconClick: (() -> Unit)? = null, + backgroundColor: Color = MaterialTheme.colors.background, actions: @Composable RowScope.() -> Unit = {} ) { TopAppBar( @@ -40,7 +42,7 @@ fun HyperskillTopAppBar( modifier = TopAppBarTitleModifier ) }, - backgroundColor = MaterialTheme.colors.background, + backgroundColor = backgroundColor, actions = actions, modifier = modifier ) diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/OnComposableShownFirstTime.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/OnComposableShownFirstTime.kt new file mode 100644 index 0000000000..6096c2abc3 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/core/view/ui/widget/compose/OnComposableShownFirstTime.kt @@ -0,0 +1,17 @@ +package org.hyperskill.app.android.core.view.ui.widget.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect + +@Composable +fun OnComposableShownFirstTime( + key: Any?, + block: () -> Unit +) { + DisposableEffect(key) { + block() + onDispose { + // no op + } + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/home/view/ui/fragment/HomeFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/home/view/ui/fragment/HomeFragment.kt index e462743e3c..bc0102176e 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/home/view/ui/fragment/HomeFragment.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/home/view/ui/fragment/HomeFragment.kt @@ -226,8 +226,8 @@ class HomeFragment : val homeState = state.homeState if (homeState is HomeFeature.HomeState.Content) { - renderProblemOfDay(viewBinding, homeState.problemOfDayState, homeState.isFreemiumEnabled) - renderTopicsRepetition(homeState.repetitionsState, homeState.isFreemiumEnabled) + renderProblemOfDay(viewBinding, homeState.problemOfDayState, homeState.areProblemsLimited) + renderTopicsRepetition(homeState.repetitionsState, homeState.areProblemsLimited) } renderChallengeCard(state.challengeWidgetViewState) @@ -281,19 +281,19 @@ class HomeFragment : private fun renderProblemOfDay( viewBinding: FragmentHomeBinding, state: HomeFeature.ProblemOfDayState, - isFreemiumEnabled: Boolean + areProblemsLimited: Boolean ) { problemOfDayCardFormDelegate.render( dateFormatter = dateFormatter, binding = viewBinding.homeScreenProblemOfDayCard, state = state, - isFreemiumEnabled = isFreemiumEnabled + areProblemsLimited = areProblemsLimited ) } private fun renderTopicsRepetition( repetitionsState: HomeFeature.RepetitionsState, - isFreemiumEnabled: Boolean + areProblemsLimited: Boolean ) { viewBinding.homeScreenTopicsRepetitionCard.root.isVisible = repetitionsState is HomeFeature.RepetitionsState.Available @@ -302,7 +302,7 @@ class HomeFragment : context = requireContext(), binding = viewBinding.homeScreenTopicsRepetitionCard, recommendedRepetitionsCount = repetitionsState.recommendedRepetitionsCount, - isFreemiumEnabled = isFreemiumEnabled + areProblemsLimited = areProblemsLimited ) } } diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/latex/view/mapper/LatexTextMapper.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/latex/view/mapper/LatexTextMapper.kt index f683e23e46..cbeaefe9b2 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/latex/view/mapper/LatexTextMapper.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/latex/view/mapper/LatexTextMapper.kt @@ -4,11 +4,13 @@ import androidx.core.text.HtmlCompat import androidx.core.text.toSpannable import org.hyperskill.app.android.latex.view.model.LatexData import org.hyperskill.app.android.latex.view.model.block.ContentBlock +import org.hyperskill.app.android.latex.view.model.block.DataMobileHiddenBlock import org.hyperskill.app.android.latex.view.model.block.HighlightScriptBlock import org.hyperskill.app.android.latex.view.model.block.HorizontalScrollBlock import org.hyperskill.app.android.latex.view.model.block.KotlinRunnableSamplesScriptBlock import org.hyperskill.app.android.latex.view.model.block.LatexScriptBlock import org.hyperskill.app.android.latex.view.model.block.MetaBlock +import org.hyperskill.app.android.latex.view.model.block.RemoveIFrameElementsInjection import org.hyperskill.app.android.latex.view.model.block.WebScriptBlock import org.hyperskill.app.android.latex.view.model.rule.RelativePathContentRule import org.hyperskill.app.android.latex.view.resolvers.OlLiTagHandler @@ -20,7 +22,9 @@ class LatexTextMapper(networkEndpointConfigInfo: NetworkEndpointConfigInfo) { HighlightScriptBlock(), KotlinRunnableSamplesScriptBlock(), LatexScriptBlock(), - WebScriptBlock() + WebScriptBlock(), + DataMobileHiddenBlock, + RemoveIFrameElementsInjection ) private val regularBlocks = diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/latex/view/model/block/DataMobileHiddenBlock.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/latex/view/model/block/DataMobileHiddenBlock.kt new file mode 100644 index 0000000000..500d760cdc --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/latex/view/model/block/DataMobileHiddenBlock.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.android.latex.view.model.block + +object DataMobileHiddenBlock : ContentBlock { + override val header: String = """ + + """.trimIndent() +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/latex/view/model/block/RemoveIFrameElementsInjection.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/latex/view/model/block/RemoveIFrameElementsInjection.kt new file mode 100644 index 0000000000..0f3e8bcd16 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/latex/view/model/block/RemoveIFrameElementsInjection.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.android.latex.view.model.block + +object RemoveIFrameElementsInjection : ContentBlock { + override val header: String = """ + + """.trimIndent() +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/main/view/ui/activity/MainActivity.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/main/view/ui/activity/MainActivity.kt index 1888cce220..f16200ec48 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/main/view/ui/activity/MainActivity.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/main/view/ui/activity/MainActivity.kt @@ -4,13 +4,16 @@ import android.annotation.SuppressLint import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle +import android.util.Log import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.isVisible +import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.flowWithLifecycle @@ -40,9 +43,13 @@ import org.hyperskill.app.android.notification.model.DefaultNotificationClickedD import org.hyperskill.app.android.notification.model.PushNotificationClickedData import org.hyperskill.app.android.notification_onboarding.fragment.NotificationsOnboardingFragment import org.hyperskill.app.android.notification_onboarding.navigation.NotificationsOnboardingScreen +import org.hyperskill.app.android.paywall.fragment.PaywallFragment +import org.hyperskill.app.android.paywall.navigation.PaywallScreen import org.hyperskill.app.android.step.view.screen.StepScreen import org.hyperskill.app.android.streak_recovery.view.delegate.StreakRecoveryViewActionDelegate import org.hyperskill.app.android.track_selection.list.navigation.TrackSelectionListScreen +import org.hyperskill.app.android.users_questionnaire.onboarding.fragment.UsersQuestionnaireOnboardingFragment +import org.hyperskill.app.android.users_questionnaire.onboarding.navigation.UsersQuestionnaireOnboardingScreen import org.hyperskill.app.android.welcome.navigation.WelcomeScreen import org.hyperskill.app.main.presentation.AppFeature import org.hyperskill.app.main.presentation.MainViewModel @@ -93,6 +100,13 @@ class MainActivity : ) } + private val onForegroundObserver = + object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + mainViewModel.onNewMessage(AppFeature.Message.AppBecomesActive) + } + } + @SuppressLint("InlinedApi") override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() @@ -111,6 +125,7 @@ class MainActivity : viewBinding = ActivityMainBinding.inflate(layoutInflater) setContentView(viewBinding.root) initViewStateDelegate() + lifecycle.addObserver(onForegroundObserver) viewBinding.mainError.tryAgain.setOnClickListener { mainViewModel.onNewMessage( @@ -126,6 +141,8 @@ class MainActivity : observeAuthFlowSuccess() observeNotificationsOnboardingFlowFinished() observeFirstProblemOnboardingFlowFinished() + observeUsersQuestionnaireOnboardingCompleted() + observePaywallCompleted() mainViewModel.logScreenOrientation(screenOrientation = resources.configuration.screenOrientation) logNotificationAvailability() @@ -157,46 +174,71 @@ class MainActivity : @SuppressLint("InlinedApi") private fun observeAuthFlowSuccess() { - lifecycleScope.launch { - router - .observeResult(AuthFragment.AUTH_SUCCESS) - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .collectLatest { - val profile = (it as? Profile) ?: return@collectLatest - mainViewModel.onNewMessage( - AppFeature.Message.UserAuthorized( - profile = profile, - isNotificationPermissionGranted = ContextCompat.checkSelfPermission( - this@MainActivity, - android.Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED - ) - ) - } + observeResult(AuthFragment.AUTH_SUCCESS) { profile -> + mainViewModel.onNewMessage( + AppFeature.Message.UserAuthorized( + profile = profile, + isNotificationPermissionGranted = ContextCompat.checkSelfPermission( + this@MainActivity, + android.Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) + ) } } private fun observeNotificationsOnboardingFlowFinished() { + observeResult(NotificationsOnboardingFragment.NOTIFICATIONS_ONBOARDING_FINISHED) { + mainViewModel.onNewMessage(WelcomeOnboardingFeature.Message.NotificationOnboardingCompleted) + } + } + + private fun observeFirstProblemOnboardingFlowFinished() { + observeResult(FirstProblemOnboardingFragment.FIRST_PROBLEM_ONBOARDING_FINISHED) { + mainViewModel.onNewMessage( + WelcomeOnboardingFeature.Message.FirstProblemOnboardingCompleted( + firstProblemStepRoute = it.safeCast() + ) + ) + } + } + + private fun observePaywallCompleted() { + observeResult(PaywallFragment.PAYWALL_COMPLETED) { + mainViewModel.onNewMessage( + WelcomeOnboardingFeature.Message.PaywallCompleted + ) + } + } + + private inline fun observeResult( + key: String, + router: Router = this.router, + crossinline onResult: (T) -> Unit + ) { lifecycleScope.launch { router - .observeResult(NotificationsOnboardingFragment.NOTIFICATIONS_ONBOARDING_FINISHED) + .observeResult(key) .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) .collectLatest { - mainViewModel.onNewMessage(WelcomeOnboardingFeature.Message.NotificationOnboardingCompleted) + val result = it.safeCast() + if (result == null) { + Log.e("MainActivity", "Can't cast result by key=$key.") + } else { + onResult(result) + } } } } - private fun observeFirstProblemOnboardingFlowFinished() { + private fun observeUsersQuestionnaireOnboardingCompleted() { lifecycleScope.launch { router - .observeResult(FirstProblemOnboardingFragment.FIRST_PROBLEM_ONBOARDING_FINISHED) + .observeResult(UsersQuestionnaireOnboardingFragment.USERS_QUESTIONNAIRE_ONBOARDING_FINISHED) .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) .collectLatest { mainViewModel.onNewMessage( - WelcomeOnboardingFeature.Message.FirstProblemOnboardingCompleted( - firstProblemStepRoute = it.safeCast() - ) + WelcomeOnboardingFeature.Message.UsersQuestionnaireOnboardingCompleted ) } } @@ -227,7 +269,7 @@ class MainActivity : override fun onAction(action: AppFeature.Action.ViewAction) { when (action) { - is AppFeature.Action.ViewAction.NavigateTo.OnboardingScreen -> + is AppFeature.Action.ViewAction.NavigateTo.WelcomeScreen -> router.newRootScreen(WelcomeScreen) is AppFeature.Action.ViewAction.NavigateTo.AuthScreen -> router.newRootScreen(AuthScreen()) @@ -251,6 +293,10 @@ class MainActivity : ) WelcomeOnboardingFeature.Action.ViewAction.NavigateTo.NotificationOnboardingScreen -> router.newRootScreen(NotificationsOnboardingScreen) + is WelcomeOnboardingFeature.Action.ViewAction.NavigateTo.Paywall -> + router.newRootScreen(PaywallScreen(viewAction.paywallTransitionSource)) + WelcomeOnboardingFeature.Action.ViewAction.NavigateTo.UsersQuestionnaireOnboardingScreen -> + router.newRootScreen(UsersQuestionnaireOnboardingScreen) } is AppFeature.Action.ViewAction.StreakRecoveryViewAction -> StreakRecoveryViewActionDelegate.handleViewAction( @@ -277,6 +323,16 @@ class MainActivity : } AppFeature.Action.ViewAction.NavigateTo.StudyPlan -> router.newRootScreen(MainScreen(Tabs.STUDY_PLAN)) + is AppFeature.Action.ViewAction.NavigateTo.Paywall -> { + if (supportFragmentManager.findFragmentByTag(PaywallScreen.TAG) == null) { + router.navigateTo(PaywallScreen(action.paywallTransitionSource)) + } + } + is AppFeature.Action.ViewAction.NavigateTo.StudyPlanWithPaywall -> + router.newRootChain( + MainScreen(initialTab = Tabs.STUDY_PLAN), + PaywallScreen(action.paywallTransitionSource) + ) } } diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/fragment/ManageSubscriptionFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/fragment/ManageSubscriptionFragment.kt new file mode 100644 index 0000000000..c562a9e9be --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/fragment/ManageSubscriptionFragment.kt @@ -0,0 +1,74 @@ +package org.hyperskill.app.android.manage_subscription.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider +import org.hyperskill.app.android.HyperskillApp +import org.hyperskill.app.android.core.extensions.openUrl +import org.hyperskill.app.android.core.view.ui.navigation.requireRouter +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.android.manage_subscription.ui.ManageSubscriptionScreen +import org.hyperskill.app.android.paywall.navigation.PaywallScreen +import org.hyperskill.app.core.view.handleActions +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Action.ViewAction +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionViewModel + +class ManageSubscriptionFragment : Fragment() { + companion object { + fun newInstance(): ManageSubscriptionFragment = + ManageSubscriptionFragment() + } + + private var viewModelFactory: ViewModelProvider.Factory? = null + private val manageSubscriptionViewModel: ManageSubscriptionViewModel by viewModels { + requireNotNull(viewModelFactory) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + injectComponent() + manageSubscriptionViewModel.handleActions(this, onAction = ::onAction) + } + + private fun injectComponent() { + val platformManageSubscriptionComponent = + HyperskillApp.graph().buildPlatformManageSubscriptionComponent() + viewModelFactory = platformManageSubscriptionComponent.reduxViewModelFactory + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner)) + setContent { + HyperskillTheme { + ManageSubscriptionScreen( + viewModel = manageSubscriptionViewModel, + onBackClick = ::onBackClick + ) + } + } + } + + private fun onAction(action: ViewAction) { + when (action) { + is ViewAction.OpenUrl -> + requireContext().openUrl(action.url) + is ViewAction.NavigateTo.Paywall -> + requireRouter().navigateTo(PaywallScreen(action.paywallTransitionSource)) + } + } + + private fun onBackClick() { + requireRouter().exit() + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/navigation/ManageSubscriptionScreen.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/navigation/ManageSubscriptionScreen.kt new file mode 100644 index 0000000000..e01551233c --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/navigation/ManageSubscriptionScreen.kt @@ -0,0 +1,11 @@ +package org.hyperskill.app.android.manage_subscription.navigation + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentFactory +import com.github.terrakok.cicerone.androidx.FragmentScreen +import org.hyperskill.app.android.manage_subscription.fragment.ManageSubscriptionFragment + +object ManageSubscriptionScreen : FragmentScreen { + override fun createFragment(factory: FragmentFactory): Fragment = + ManageSubscriptionFragment.newInstance() +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/ui/ManageSubscriptionContent.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/ui/ManageSubscriptionContent.kt new file mode 100644 index 0000000000..a9e2180389 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/ui/ManageSubscriptionContent.kt @@ -0,0 +1,149 @@ +package org.hyperskill.app.android.manage_subscription.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.hyperskill.app.R +import org.hyperskill.app.android.core.extensions.compose.plus +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillButton +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.android.paywall.ui.SubscriptionDetails +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.ViewState + +@Composable +fun ManageSubscriptionContent( + state: ViewState.Content, + onActionButtonClick: () -> Unit, + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues() +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .background(colorResource(id = R.color.layer_1)) + .padding(ManageSubscriptionDefaults.ContentPadding + padding) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.weight(1f) + ) { + SubscriptionHeader(validUntilFormatted = state.validUntilFormatted) + PlanDetails() + MobileOnlyWarning() + Spacer(modifier = Modifier.height(24.dp)) + } + + val buttonText = state.buttonText + if (buttonText != null) { + HyperskillButton( + onClick = onActionButtonClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = buttonText) + } + } + } +} + +@Composable +private fun SubscriptionHeader( + validUntilFormatted: String?, + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + Text( + text = stringResource(id = R.string.manage_subscription_plan_title), + style = MaterialTheme.typography.body2 + ) + Text( + text = stringResource(id = R.string.manage_subscription_mobile_only), + style = MaterialTheme.typography.h5 + ) + if (validUntilFormatted != null) { + Text( + text = validUntilFormatted, + style = MaterialTheme.typography.body2 + ) + } + } +} + +@Composable +private fun PlanDetails(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Text( + text = stringResource(id = R.string.manage_subscription_plan_details_title), + style = MaterialTheme.typography.h6, + fontSize = 16.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + SubscriptionDetails() + } +} + +@Composable +private fun MobileOnlyWarning( + modifier: Modifier = Modifier +) { + Text( + text = stringResource(id = R.string.manage_subscription_mobile_only_warning), + color = colorResource(id = R.color.color_primary), + modifier = modifier + .clip(RoundedCornerShape(dimensionResource(id = org.hyperskill.app.android.R.dimen.corner_radius))) + .background(colorResource(id = R.color.color_overlay_blue_alpha_12)) + .padding(horizontal = 16.dp, vertical = 14.dp) + ) +} + +@Preview +@Composable +private fun SubscriptionHeaderPreview() { + HyperskillTheme { + SubscriptionHeader( + validUntilFormatted = ManageSubscriptionPreviewDefaults.VALID_UNTIL_FORMATTED + ) + } +} + +@Preview +@Composable +fun MobileOnlyWarningPreview() { + HyperskillTheme { + MobileOnlyWarning() + } +} + +@Preview +@Composable +private fun ManageSubscriptionContentPreview() { + HyperskillTheme { + ManageSubscriptionContent( + state = ManageSubscriptionPreviewDefaults.ActiveSubscriptionContent, + onActionButtonClick = {} + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/ui/ManageSubscriptionDefaults.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/ui/ManageSubscriptionDefaults.kt new file mode 100644 index 0000000000..9107ebed9e --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/ui/ManageSubscriptionDefaults.kt @@ -0,0 +1,13 @@ +package org.hyperskill.app.android.manage_subscription.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.dp + +object ManageSubscriptionDefaults { + val ContentPadding = PaddingValues( + start = 24.dp, + end = 20.dp, + top = 24.dp, + bottom = 32.dp + ) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/ui/ManageSubscriptionPreviewDefaults.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/ui/ManageSubscriptionPreviewDefaults.kt new file mode 100644 index 0000000000..fd8bb77d82 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/ui/ManageSubscriptionPreviewDefaults.kt @@ -0,0 +1,19 @@ +package org.hyperskill.app.android.manage_subscription.ui + +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature + +object ManageSubscriptionPreviewDefaults { + const val VALID_UNTIL_FORMATTED = "Valid until January 27, 2024, 02:00" + + val ActiveSubscriptionContent: ManageSubscriptionFeature.ViewState.Content + get() = ManageSubscriptionFeature.ViewState.Content( + validUntilFormatted = VALID_UNTIL_FORMATTED, + buttonText = "Manage subscription" + ) + + val ExpiredSubscriptionContent: ManageSubscriptionFeature.ViewState.Content + get() = ManageSubscriptionFeature.ViewState.Content( + validUntilFormatted = VALID_UNTIL_FORMATTED, + buttonText = "Renew subscription" + ) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/ui/ManageSubscriptionScreen.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/ui/ManageSubscriptionScreen.kt new file mode 100644 index 0000000000..6e1f184182 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/manage_subscription/ui/ManageSubscriptionScreen.kt @@ -0,0 +1,111 @@ +package org.hyperskill.app.android.manage_subscription.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillProgressBar +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTopAppBar +import org.hyperskill.app.android.core.view.ui.widget.compose.OnComposableShownFirstTime +import org.hyperskill.app.android.core.view.ui.widget.compose.ScreenDataLoadingError +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Message +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.ViewState +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionViewModel + +@Composable +fun ManageSubscriptionScreen( + viewModel: ManageSubscriptionViewModel, + onBackClick: () -> Unit +) { + OnComposableShownFirstTime(viewModel) { + viewModel.onNewMessage(Message.ViewedEventMessage) + } + val viewState by viewModel.state.collectAsStateWithLifecycle() + ManageSubscriptionScreen( + viewState = viewState, + onBackClick = onBackClick, + onRetryLoadingClick = viewModel::onRetryClick, + onActionButtonClick = viewModel::onActionButtonClick + ) +} + +@Composable +fun ManageSubscriptionScreen( + viewState: ViewState, + onBackClick: () -> Unit, + onRetryLoadingClick: () -> Unit, + onActionButtonClick: () -> Unit +) { + Scaffold( + topBar = { + HyperskillTopAppBar( + title = stringResource(id = R.string.manage_subscription_screen_title), + onNavigationIconClick = onBackClick, + backgroundColor = colorResource(id = R.color.layer_1) + ) + } + ) { padding -> + when (viewState) { + ViewState.Idle -> { + // no op + } + ViewState.Loading -> { + Box(modifier = Modifier.fillMaxSize()) { + HyperskillProgressBar( + modifier = Modifier.align(Alignment.Center) + ) + } + } + ViewState.Error -> { + ScreenDataLoadingError( + errorMessage = stringResource(id = R.string.paywall_placeholder_error_description) + ) { + onRetryLoadingClick() + } + } + is ViewState.Content -> { + ManageSubscriptionContent( + state = viewState, + onActionButtonClick = onActionButtonClick, + padding = padding + ) + } + } + } +} + +private class ManageSubscriptionPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + ManageSubscriptionPreviewDefaults.ActiveSubscriptionContent, + ManageSubscriptionPreviewDefaults.ExpiredSubscriptionContent, + ViewState.Loading, + ViewState.Error + ) +} + +@Preview(showBackground = true) +@Composable +fun ManageSubscriptionScreenPreview( + @PreviewParameter(ManageSubscriptionPreviewProvider::class) viewState: ViewState +) { + HyperskillTheme { + ManageSubscriptionScreen( + viewState = viewState, + onBackClick = {}, + onActionButtonClick = {}, + onRetryLoadingClick = {} + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/notification/local/DailyStudyReminderLocalNotificationDelegate.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/notification/local/DailyStudyReminderLocalNotificationDelegate.kt index 471486b6a4..6248936c1b 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/notification/local/DailyStudyReminderLocalNotificationDelegate.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/notification/local/DailyStudyReminderLocalNotificationDelegate.kt @@ -6,7 +6,6 @@ import java.text.SimpleDateFormat import java.util.Calendar import kotlinx.coroutines.runBlocking import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor -import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute import org.hyperskill.app.android.core.extensions.DateTimeHelper import org.hyperskill.app.android.notification.NotificationBuilder import org.hyperskill.app.android.notification.NotificationIntentBuilder @@ -111,7 +110,6 @@ class DailyStudyReminderLocalNotificationDelegate( private fun logShownNotificationEvent(notificationId: Int) { val event = NotificationDailyStudyReminderShownHyperskillAnalyticEvent( - route = HyperskillAnalyticRoute.Home(), notificationId = notificationId, plannedAtISO8601 = SimpleDateFormat(DateTimeHelper.ISO_PATTERN).format(Calendar.getInstance().time) ) diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/fragment/PaywallFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/fragment/PaywallFragment.kt new file mode 100644 index 0000000000..4e43f5a1bf --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/fragment/PaywallFragment.kt @@ -0,0 +1,106 @@ +package org.hyperskill.app.android.paywall.fragment + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider +import org.hyperskill.app.android.HyperskillApp +import org.hyperskill.app.android.core.extensions.setHyperskillColors +import org.hyperskill.app.android.core.view.ui.navigation.requireAppRouter +import org.hyperskill.app.android.core.view.ui.navigation.requireRouter +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.android.main.view.ui.navigation.MainScreen +import org.hyperskill.app.android.main.view.ui.navigation.Tabs +import org.hyperskill.app.android.paywall.ui.PaywallScreen +import org.hyperskill.app.core.view.handleActions +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.paywall.presentation.PaywallFeature.Action.ViewAction +import org.hyperskill.app.paywall.presentation.PaywallViewModel +import ru.nobird.android.view.base.ui.extension.argument + +class PaywallFragment : Fragment() { + companion object { + const val PAYWALL_COMPLETED = "PAYWALL_COMPLETED" + + fun newInstance(paywallTransitionSource: PaywallTransitionSource): PaywallFragment = + PaywallFragment().apply { + this.paywallTransitionSource = paywallTransitionSource + } + } + + private var paywallTransitionSource: PaywallTransitionSource by argument() + + private var viewModelFactory: ViewModelProvider.Factory? = null + private val paywallViewModel: PaywallViewModel by viewModels { + requireNotNull(viewModelFactory) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + injectComponent() + paywallViewModel.handleActions(this, onAction = ::onAction) + } + + private fun injectComponent() { + val platformPaywallComponent = + HyperskillApp.graph().buildPlatformPaywallComponent(paywallTransitionSource) + viewModelFactory = platformPaywallComponent.reduxViewModelFactory + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner)) + setContent { + HyperskillTheme { + PaywallScreen( + viewModel = paywallViewModel, + onBackClick = ::onBackClick + ) + } + } + } + + private fun onAction(action: ViewAction) { + when (action) { + ViewAction.CompletePaywall -> { + requireAppRouter().sendResult(PAYWALL_COMPLETED, Any()) + } + ViewAction.StudyPlan -> { + requireRouter().backTo(MainScreen(initialTab = Tabs.STUDY_PLAN)) + } + is ViewAction.ShowMessage -> { + Toast.makeText( + requireContext(), + getString(action.messageKind.stringRes.resourceId), + Toast.LENGTH_SHORT + ).show() + } + ViewAction.NavigateTo.BackToProfileSettings -> + requireRouter().backTo(MainScreen(Tabs.PROFILE)) + ViewAction.ClosePaywall -> + requireRouter().exit() + is ViewAction.OpenUrl -> { + val intent = CustomTabsIntent.Builder() + .setHyperskillColors(requireContext()) + .build() + intent.launchUrl(requireContext(), Uri.parse(action.url)) + } + } + } + + private fun onBackClick() { + requireRouter().exit() + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/navigation/PaywallScreen.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/navigation/PaywallScreen.kt new file mode 100644 index 0000000000..86e8864791 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/navigation/PaywallScreen.kt @@ -0,0 +1,21 @@ +package org.hyperskill.app.android.paywall.navigation + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentFactory +import com.github.terrakok.cicerone.androidx.FragmentScreen +import org.hyperskill.app.android.paywall.fragment.PaywallFragment +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource + +class PaywallScreen( + private val paywallTransitionSource: PaywallTransitionSource +) : FragmentScreen { + companion object { + const val TAG: String = "PaywallScreen" + } + + override val screenKey: String + get() = TAG + + override fun createFragment(factory: FragmentFactory): Fragment = + PaywallFragment.newInstance(paywallTransitionSource) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallContent.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallContent.kt new file mode 100644 index 0000000000..ed16ce5011 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallContent.kt @@ -0,0 +1,115 @@ +package org.hyperskill.app.android.paywall.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.hyperskill.app.R +import org.hyperskill.app.android.core.extensions.compose.plus +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillButton +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillButtonDefaults +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme + +@Composable +fun PaywallContent( + buyButtonText: String, + isContinueWithLimitsButtonVisible: Boolean, + onTermsOfServiceClick: () -> Unit, + onBuySubscriptionClick: () -> Unit, + onContinueWithLimitsClick: () -> Unit, + modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues() +) { + Column( + modifier = modifier + .fillMaxSize() + .background(PaywallDefaults.BackgroundColor) + .padding(PaywallDefaults.ContentPadding + padding) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.weight(1f) + ) { + Image( + painter = painterResource(id = org.hyperskill.app.android.R.drawable.img_paywall), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterHorizontally) + ) + Text( + text = stringResource(id = R.string.paywall_mobile_only_title), + style = MaterialTheme.typography.h5, + fontWeight = FontWeight.Medium + ) + SubscriptionDetails() + } + Column { + HyperskillButton( + onClick = onBuySubscriptionClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = buyButtonText) + } + Spacer(modifier = Modifier.height(8.dp)) + if (isContinueWithLimitsButtonVisible) { + HyperskillButton( + onClick = onContinueWithLimitsClick, + colors = HyperskillButtonDefaults.buttonColors(colorResource(id = R.color.layer_1)), + border = BorderStroke(1.dp, colorResource(id = R.color.button_tertiary)), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(id = R.string.paywall_mobile_only_continue_btn), + color = colorResource(id = R.color.button_tertiary) + ) + } + } + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = stringResource(id = R.string.paywall_tos_and_privacy_bth), + fontSize = 12.sp, + color = colorResource(id = R.color.color_on_surface_alpha_60), + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .clickable(onClick = onTermsOfServiceClick) + ) + } + } +} + +@Preview +@Composable +fun PaywallContentPreview() { + HyperskillTheme { + PaywallContent( + buyButtonText = PaywallPreviewDefaults.BUY_BUTTON_TEXT, + isContinueWithLimitsButtonVisible = true, + onTermsOfServiceClick = {}, + onBuySubscriptionClick = {}, + onContinueWithLimitsClick = {} + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallPreviewDefaults.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallPreviewDefaults.kt new file mode 100644 index 0000000000..3586120773 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallPreviewDefaults.kt @@ -0,0 +1,5 @@ +package org.hyperskill.app.android.paywall.ui + +object PaywallPreviewDefaults { + const val BUY_BUTTON_TEXT = "Subscribe for $12.00/month" +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallScreen.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallScreen.kt new file mode 100644 index 0000000000..43f541853c --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/PaywallScreen.kt @@ -0,0 +1,175 @@ +package org.hyperskill.app.android.paywall.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.hyperskill.app.R +import org.hyperskill.app.android.core.extensions.findActivity +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillProgressBar +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTopAppBar +import org.hyperskill.app.android.core.view.ui.widget.compose.OnComposableShownFirstTime +import org.hyperskill.app.android.core.view.ui.widget.compose.ScreenDataLoadingError +import org.hyperskill.app.paywall.presentation.PaywallFeature +import org.hyperskill.app.paywall.presentation.PaywallFeature.ViewState +import org.hyperskill.app.paywall.presentation.PaywallFeature.ViewStateContent +import org.hyperskill.app.paywall.presentation.PaywallViewModel + +object PaywallDefaults { + val ContentPadding = PaddingValues( + start = 24.dp, + end = 20.dp, + top = 24.dp, + bottom = 32.dp + ) + + val BackgroundColor: Color + @Composable + get() = colorResource(id = R.color.layer_1) +} + +@Composable +fun PaywallScreen( + viewModel: PaywallViewModel, + onBackClick: () -> Unit +) { + OnComposableShownFirstTime(viewModel) { + viewModel.onNewMessage(PaywallFeature.Message.ViewedEventMessage) + } + val state by viewModel.state.collectAsStateWithLifecycle() + val activity = LocalContext.current.findActivity() + PaywallScreen( + viewState = state, + onBackClick = onBackClick, + onBuySubscriptionClick = remember(activity) { + { + viewModel.onBuySubscriptionClick(activity) + } + }, + onContinueWithLimitsClick = viewModel::onContinueWithLimitsClick, + onRetryLoadingClick = viewModel::onRetryLoadingClicked, + onTermsOfServiceClick = viewModel::onTermsOfServiceClick + ) +} + +@Composable +fun PaywallScreen( + viewState: ViewState, + onBackClick: () -> Unit, + onBuySubscriptionClick: () -> Unit, + onContinueWithLimitsClick: () -> Unit, + onRetryLoadingClick: () -> Unit, + onTermsOfServiceClick: () -> Unit +) { + Scaffold( + topBar = { + if (viewState.isToolbarVisible) { + HyperskillTopAppBar( + title = stringResource(id = R.string.paywall_screen_title), + onNavigationIconClick = onBackClick, + backgroundColor = PaywallDefaults.BackgroundColor + ) + } + } + ) { padding -> + when (val contentState = viewState.contentState) { + ViewStateContent.Idle -> { + // no op + } + ViewStateContent.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(PaywallDefaults.BackgroundColor) + ) { + HyperskillProgressBar( + modifier = Modifier.align(Alignment.Center) + ) + } + } + ViewStateContent.Error -> + ScreenDataLoadingError( + errorMessage = stringResource(id = R.string.paywall_placeholder_error_description), + modifier = Modifier.background(PaywallDefaults.BackgroundColor) + ) { + onRetryLoadingClick() + } + is ViewStateContent.Content -> + PaywallContent( + buyButtonText = contentState.buyButtonText, + isContinueWithLimitsButtonVisible = contentState.isContinueWithLimitsButtonVisible, + onBuySubscriptionClick = onBuySubscriptionClick, + onContinueWithLimitsClick = onContinueWithLimitsClick, + onTermsOfServiceClick = onTermsOfServiceClick, + padding = padding + ) + ViewStateContent.SubscriptionSyncLoading -> + SubscriptionSyncLoading() + } + } +} + +private class PaywallPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + ViewState( + isToolbarVisible = true, + contentState = ViewStateContent.Content( + buyButtonText = PaywallPreviewDefaults.BUY_BUTTON_TEXT, + isContinueWithLimitsButtonVisible = false + ) + ), + ViewState( + isToolbarVisible = false, + contentState = ViewStateContent.Content( + buyButtonText = PaywallPreviewDefaults.BUY_BUTTON_TEXT, + isContinueWithLimitsButtonVisible = true + ) + ), + ViewState( + isToolbarVisible = true, + contentState = ViewStateContent.Error + ), + ViewState( + isToolbarVisible = true, + contentState = ViewStateContent.Loading + ), + ViewState( + isToolbarVisible = true, + contentState = ViewStateContent.SubscriptionSyncLoading + ) + ) +} + +@Preview(showBackground = true) +@Composable +fun PaywallScreenPreview( + @PreviewParameter(provider = PaywallPreviewProvider::class) viewState: ViewState +) { + HyperskillTheme { + PaywallScreen( + viewState = viewState, + onBackClick = {}, + onBuySubscriptionClick = {}, + onContinueWithLimitsClick = {}, + onRetryLoadingClick = {}, + onTermsOfServiceClick = {} + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionDetails.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionDetails.kt new file mode 100644 index 0000000000..0e4265ef6a --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionDetails.kt @@ -0,0 +1,63 @@ +package org.hyperskill.app.android.paywall.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.hyperskill.app.android.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.R as SharedR + +@Composable +fun SubscriptionDetails(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + SubscriptionOption(text = stringResource(id = SharedR.string.mobile_only_subscription_feature_1)) + SubscriptionOption(text = stringResource(id = SharedR.string.mobile_only_subscription_feature_2)) + SubscriptionOption(text = stringResource(id = SharedR.string.mobile_only_subscription_feature_3)) + } +} + +@Composable +fun SubscriptionOption( + text: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_paywall_option), + contentDescription = null, + modifier = Modifier.align(Alignment.CenterVertically) + ) + Text( + text = text, + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ) + } +} + +@Preview +@Composable +private fun PaywallSubscriptionDetailsPreview() { + HyperskillTheme { + SubscriptionDetails() + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionSyncLoading.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionSyncLoading.kt new file mode 100644 index 0000000000..3b3f5e682b --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/paywall/ui/SubscriptionSyncLoading.kt @@ -0,0 +1,57 @@ +package org.hyperskill.app.android.paywall.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.hyperskill.app.android.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.android.core.view.ui.widget.compose.centerWithVerticalBias +import org.hyperskill.app.R as SharedR + +@Composable +fun SubscriptionSyncLoading( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxSize() + .background(PaywallDefaults.BackgroundColor) + ) { + Column(modifier = Modifier.align(Alignment.centerWithVerticalBias(-0.5f))) { + Image( + painter = painterResource(id = R.drawable.img_reload), + contentDescription = null, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = SharedR.string.paywall_subscription_sync_description), + style = MaterialTheme.typography.h6, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + } +} + +@Preview +@Composable +private fun SubscriptionSyncLoadingPreview() { + HyperskillTheme { + SubscriptionSyncLoading() + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problem_of_day/view/delegate/ProblemOfDayCardFormDelegate.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problem_of_day/view/delegate/ProblemOfDayCardFormDelegate.kt index 166bdfd3c5..4166c38838 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problem_of_day/view/delegate/ProblemOfDayCardFormDelegate.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problem_of_day/view/delegate/ProblemOfDayCardFormDelegate.kt @@ -25,7 +25,7 @@ class ProblemOfDayCardFormDelegate( dateFormatter: SharedDateFormatter, binding: LayoutProblemOfTheDayCardBinding, state: HomeFeature.ProblemOfDayState, - isFreemiumEnabled: Boolean + areProblemsLimited: Boolean ) { with(binding) { when (state) { @@ -112,13 +112,13 @@ class ProblemOfDayCardFormDelegate( } } } - renderFooter(binding, state, isFreemiumEnabled) + renderFooter(binding, state, areProblemsLimited) } private fun renderFooter( binding: LayoutProblemOfTheDayCardBinding, state: HomeFeature.ProblemOfDayState, - isFreemiumEnabled: Boolean + areProblemsLimited: Boolean ) { val needToRefresh = when (state) { HomeFeature.ProblemOfDayState.Empty -> false @@ -142,8 +142,8 @@ class ProblemOfDayCardFormDelegate( problemOfDayNextProblemInCounterView.text = nextProblemIn } } - binding.problemOfDayFreemiumBadge.isVisible = when (state) { - is HomeFeature.ProblemOfDayState.NeedToSolve -> isFreemiumEnabled + binding.problemOfDayUnlimitedBadge.isVisible = when (state) { + is HomeFeature.ProblemOfDayState.NeedToSolve -> areProblemsLimited HomeFeature.ProblemOfDayState.Empty, is HomeFeature.ProblemOfDayState.Solved -> false } diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problems_limit/dialog/ProblemsLimitReachedBottomSheet.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problems_limit/dialog/ProblemsLimitReachedBottomSheet.kt index e92c7c3883..b5616d939e 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problems_limit/dialog/ProblemsLimitReachedBottomSheet.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problems_limit/dialog/ProblemsLimitReachedBottomSheet.kt @@ -6,17 +6,18 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.fragment.app.viewModels import by.kirich1409.viewbindingdelegate.viewBinding import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import org.hyperskill.app.android.R +import org.hyperskill.app.android.core.extensions.argument import org.hyperskill.app.android.databinding.FragmentProblemsLimitReachedBinding import org.hyperskill.app.android.view.base.ui.extension.wrapWithTheme import org.hyperskill.app.step_quiz.presentation.StepQuizFeature import org.hyperskill.app.step_quiz.presentation.StepQuizViewModel -import ru.nobird.android.view.base.ui.extension.argument class ProblemsLimitReachedBottomSheet : BottomSheetDialogFragment() { @@ -24,13 +25,17 @@ class ProblemsLimitReachedBottomSheet : BottomSheetDialogFragment() { const val TAG = "ProblemsLimitReachedBottomSheet" - fun newInstance(modalText: String): ProblemsLimitReachedBottomSheet = + fun newInstance( + modalData: StepQuizFeature.ProblemsLimitReachedModalData + ): ProblemsLimitReachedBottomSheet = ProblemsLimitReachedBottomSheet().apply { - this.modalText = modalText + this.modalData = modalData } } - private var modalText: String by argument() + private var modalData: StepQuizFeature.ProblemsLimitReachedModalData by argument( + serializer = StepQuizFeature.ProblemsLimitReachedModalData.serializer() + ) private val viewBinding: FragmentProblemsLimitReachedBinding by viewBinding( FragmentProblemsLimitReachedBinding::bind @@ -71,7 +76,21 @@ class ProblemsLimitReachedBottomSheet : BottomSheetDialogFragment() { problemsLimitReachedHomeButton.setOnClickListener { viewModel.onNewMessage(StepQuizFeature.Message.ProblemsLimitReachedModalGoToHomeScreenClicked) } - problemsLimitReachedDescription.text = modalText + + problemsLimitReachedModalTitle.text = modalData.title + problemsLimitReachedDescription.text = modalData.description + + if (modalData.unlockLimitsButtonText != null) { + problemsLimitReachedUnlimitedProblemsButton.isVisible = true + problemsLimitReachedUnlimitedProblemsButton.text = modalData.unlockLimitsButtonText + problemsLimitReachedUnlimitedProblemsButton.setOnClickListener { + viewModel.onNewMessage( + StepQuizFeature.Message.ProblemsLimitReachedModalUnlockUnlimitedProblemsClicked + ) + } + } else { + problemsLimitReachedUnlimitedProblemsButton.isVisible = false + } } } diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problems_limit/view/ui/delegate/ProblemsLimitDelegate.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problems_limit/view/ui/delegate/ProblemsLimitDelegate.kt index a6b7f6b5ad..cbb16ada5c 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problems_limit/view/ui/delegate/ProblemsLimitDelegate.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/problems_limit/view/ui/delegate/ProblemsLimitDelegate.kt @@ -20,22 +20,23 @@ class ProblemsLimitDelegate( addState() addState( + viewBinding.root, viewBinding.problemsLimitSkeleton ) addState( + viewBinding.root, viewBinding.problemsLimitsContent ) addState( + viewBinding.root, viewBinding.problemsLimitRetryButton ) } viewBinding.problemsLimitRetryButton.setOnClickListener { - onNewMessage( - ProblemsLimitFeature.Message.Initialize(forceUpdate = true) - ) + onNewMessage(ProblemsLimitFeature.Message.RetryContentLoading) } } diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/profile_settings/view/dialog/ProfileSettingsDialogFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/profile_settings/view/dialog/ProfileSettingsDialogFragment.kt index d3a18551ab..fdaed8722b 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/profile_settings/view/dialog/ProfileSettingsDialogFragment.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/profile_settings/view/dialog/ProfileSettingsDialogFragment.kt @@ -1,5 +1,6 @@ package org.hyperskill.app.android.profile_settings.view.dialog +import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri @@ -7,6 +8,7 @@ import android.os.Bundle import android.util.TypedValue import android.view.View import androidx.appcompat.app.AppCompatDelegate +import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider import by.kirich1409.viewbindingdelegate.viewBinding @@ -14,17 +16,22 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.hyperskill.app.SharedResources import org.hyperskill.app.android.HyperskillApp import org.hyperskill.app.android.R +import org.hyperskill.app.android.core.extensions.getStringRepresentation import org.hyperskill.app.android.core.extensions.openUrl -import org.hyperskill.app.android.core.extensions.representation import org.hyperskill.app.android.core.view.ui.dialog.LoadingProgressDialogFragment import org.hyperskill.app.android.core.view.ui.dialog.dismissDialogFragmentIfExists +import org.hyperskill.app.android.core.view.ui.navigation.requireRouter import org.hyperskill.app.android.databinding.FragmentProfileSettingsBinding +import org.hyperskill.app.android.manage_subscription.navigation.ManageSubscriptionScreen +import org.hyperskill.app.android.paywall.navigation.PaywallScreen import org.hyperskill.app.android.profile_settings.view.mapper.asNightMode import org.hyperskill.app.android.view.base.ui.extension.snackbar import org.hyperskill.app.profile.presentation.ProfileSettingsViewModel import org.hyperskill.app.profile_settings.domain.model.FeedbackEmailData import org.hyperskill.app.profile_settings.domain.model.Theme -import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Action +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Message +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.ViewState import ru.nobird.android.view.base.ui.delegate.ViewStateDelegate import ru.nobird.android.view.base.ui.extension.showIfNotExists import ru.nobird.android.view.redux.ui.extension.reduxViewModel @@ -32,7 +39,7 @@ import ru.nobird.app.presentation.redux.container.ReduxView class ProfileSettingsDialogFragment : DialogFragment(R.layout.fragment_profile_settings), - ReduxView { + ReduxView { companion object { const val TAG = "ProfileSettingsDialogFragment" @@ -44,7 +51,7 @@ class ProfileSettingsDialogFragment : private lateinit var viewModelFactory: ViewModelProvider.Factory private val profileSettingsViewModel: ProfileSettingsViewModel by reduxViewModel(this) { viewModelFactory } - private val viewStateDelegate: ViewStateDelegate = ViewStateDelegate() + private var viewStateDelegate: ViewStateDelegate? = null private var currentThemePosition: Int = -1 @@ -64,30 +71,32 @@ class ProfileSettingsDialogFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + initViewStateDelegate(viewBinding) + with(viewBinding.settingsCenteredToolbar) { centeredToolbarTitle.setText(org.hyperskill.app.R.string.settings_title) centeredToolbarTitle.setTextAppearance(androidx.appcompat.R.style.TextAppearance_AppCompat_Body2) centeredToolbarTitle.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18F) centeredToolbar.setNavigationOnClickListener { - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.ClickedDoneEventMessage) + profileSettingsViewModel.onNewMessage(Message.ClickedDoneEventMessage) dismiss() } centeredToolbar.setNavigationIcon(R.drawable.ic_close_thin) } - - viewBinding.settingsThemeButton.setOnClickListener { - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.ClickedThemeEventMessage) + viewBinding.settingsContent.settingsThemeButton.setOnClickListener { + profileSettingsViewModel.onNewMessage(Message.ClickedThemeEventMessage) MaterialAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_App_MaterialAlertDialog) .setTitle(org.hyperskill.app.R.string.settings_theme) .setSingleChoiceItems( - Theme.values().map { theme -> theme.representation }.toTypedArray(), + Theme.values().map { theme -> theme.getStringRepresentation(requireContext()) }.toTypedArray(), currentThemePosition ) { _, which -> val newTheme = Theme.values()[which] - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.ThemeChanged(theme = newTheme)) - viewBinding.settingsThemeChosenTextView.text = newTheme.representation + profileSettingsViewModel.onNewMessage(Message.ThemeChanged(theme = newTheme)) + viewBinding.settingsContent.settingsThemeChosenTextView.text = + newTheme.getStringRepresentation(requireContext()) AppCompatDelegate.setDefaultNightMode(newTheme.asNightMode()) } .setNegativeButton(org.hyperskill.app.R.string.cancel) { dialog, _ -> @@ -96,30 +105,43 @@ class ProfileSettingsDialogFragment : .show() } - viewBinding.settingsTermsOfServiceButton.setOnClickListener { - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.ClickedTermsOfServiceEventMessage) + viewBinding.settingsContent.settingsTermsOfServiceButton.setOnClickListener { + profileSettingsViewModel.onNewMessage(Message.ClickedTermsOfServiceEventMessage) openLinkInBrowser(resources.getString(org.hyperskill.app.R.string.settings_terms_of_service_url)) } - viewBinding.settingsPrivacyPolicyButton.setOnClickListener { - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.ClickedPrivacyPolicyEventMessage) + viewBinding.settingsContent.settingsPrivacyPolicyButton.setOnClickListener { + profileSettingsViewModel.onNewMessage(Message.ClickedPrivacyPolicyEventMessage) openLinkInBrowser(resources.getString(org.hyperskill.app.R.string.settings_privacy_policy_url)) } - viewBinding.settingsReportProblemButton.setOnClickListener { - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.ClickedReportProblemEventMessage) + viewBinding.settingsContent.settingsReportProblemButton.setOnClickListener { + profileSettingsViewModel.onNewMessage(Message.ClickedReportProblemEventMessage) openLinkInBrowser(resources.getString(org.hyperskill.app.R.string.settings_report_problem_url)) } - viewBinding.settingsSendFeedbackButton.setOnClickListener { - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.ClickedSendFeedback) + viewBinding.settingsContent.settingsSendFeedbackButton.setOnClickListener { + profileSettingsViewModel.onNewMessage(Message.ClickedSendFeedback) + } + + viewBinding.settingsContent.settingsSubscriptionFrameLayout.setOnClickListener { + profileSettingsViewModel.onNewMessage(Message.SubscriptionDetailsClicked) + } + + viewBinding.settingsContent.settingsRateAppButton.setOnClickListener { + profileSettingsViewModel.onNewMessage(Message.ClickedRateUsInPlayStoreEventMessage) + requireContext().openUrl( + getString(org.hyperskill.app.R.string.settings_rate_in_google_play_url) + ) } val userAgentInfo = HyperskillApp.graph().commonComponent.userAgentInfo - viewBinding.settingsVersionTextView.text = "${userAgentInfo.versionName} (${userAgentInfo.versionCode})" + @SuppressLint("SetTextI18n") + viewBinding.settingsContent.settingsVersionTextView.text = + "${userAgentInfo.versionName} (${userAgentInfo.versionCode})" - viewBinding.settingsLogoutButton.setOnClickListener { - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.ClickedSignOutEventMessage) + viewBinding.settingsContent.settingsLogoutButton.setOnClickListener { + profileSettingsViewModel.onNewMessage(Message.ClickedSignOutEventMessage) MaterialAlertDialogBuilder( requireContext(), @@ -129,15 +151,15 @@ class ProfileSettingsDialogFragment : .setMessage(org.hyperskill.app.R.string.settings_sign_out_dialog_explanation) .setPositiveButton(org.hyperskill.app.R.string.yes) { _, _ -> profileSettingsViewModel.onNewMessage( - ProfileSettingsFeature.Message.SignOutNoticeHiddenEventMessage( + Message.SignOutNoticeHiddenEventMessage( isConfirmed = true ) ) - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.SignOutConfirmed) + profileSettingsViewModel.onNewMessage(Message.SignOutConfirmed) } .setNegativeButton(org.hyperskill.app.R.string.no) { dialog, _ -> profileSettingsViewModel.onNewMessage( - ProfileSettingsFeature.Message.SignOutNoticeHiddenEventMessage( + Message.SignOutNoticeHiddenEventMessage( isConfirmed = false ) ) @@ -145,11 +167,11 @@ class ProfileSettingsDialogFragment : } .show() - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.SignOutNoticeShownEventMessage) + profileSettingsViewModel.onNewMessage(Message.SignOutNoticeShownEventMessage) } - viewBinding.settingsDeleteAccountButton.setOnClickListener { - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.ClickedDeleteAccountEventMessage) + viewBinding.settingsContent.settingsDeleteAccountButton.setOnClickListener { + profileSettingsViewModel.onNewMessage(Message.ClickedDeleteAccountEventMessage) MaterialAlertDialogBuilder( requireContext(), @@ -161,57 +183,88 @@ class ProfileSettingsDialogFragment : org.hyperskill.app.R.string.settings_account_deletion_dialog_delete_button_text ) { _, _ -> profileSettingsViewModel.onNewMessage( - ProfileSettingsFeature.Message.DeleteAccountNoticeHidden(true) + Message.DeleteAccountNoticeHidden(true) ) } .setNegativeButton(org.hyperskill.app.R.string.cancel) { dialog, _ -> profileSettingsViewModel.onNewMessage( - ProfileSettingsFeature.Message.DeleteAccountNoticeHidden(false) + Message.DeleteAccountNoticeHidden(false) ) dialog.dismiss() } .show() - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.DeleteAccountNoticeShownEventMessage) + profileSettingsViewModel.onNewMessage(Message.DeleteAccountNoticeShownEventMessage) + } + + profileSettingsViewModel.onNewMessage(Message.InitMessage) + profileSettingsViewModel.onNewMessage(Message.ViewedEventMessage) + } + + private fun initViewStateDelegate(viewBinding: FragmentProfileSettingsBinding) { + viewStateDelegate = ViewStateDelegate().apply { + addState() + addState(viewBinding.settingsProgress) + addState(viewBinding.settingsContent.root) } + } - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.InitMessage()) - profileSettingsViewModel.onNewMessage(ProfileSettingsFeature.Message.ViewedEventMessage) + override fun onDestroyView() { + super.onDestroyView() + viewStateDelegate = null } private fun openLinkInBrowser(link: String) { requireContext().openUrl(link) } - override fun onAction(action: ProfileSettingsFeature.Action.ViewAction) { + override fun onAction(action: Action.ViewAction) { when (action) { - is ProfileSettingsFeature.Action.ViewAction.SendFeedback -> + is Action.ViewAction.SendFeedback -> sendEmailFeedback(action.feedbackEmailData) - is ProfileSettingsFeature.Action.ViewAction.OpenUrl -> + is Action.ViewAction.OpenUrl -> openLinkInBrowser(action.url) - is ProfileSettingsFeature.Action.ViewAction.ShowGetMagicLinkError -> + is Action.ViewAction.ShowGetMagicLinkError -> viewBinding.root.snackbar(SharedResources.strings.common_error.resourceId) + is Action.ViewAction.NavigateTo.Paywall -> { + requireRouter() + .navigateTo(PaywallScreen(action.paywallTransitionSource)) + } + is Action.ViewAction.NavigateTo.SubscriptionManagement -> { + requireRouter().navigateTo(ManageSubscriptionScreen) + } else -> { // no op } } } - override fun render(state: ProfileSettingsFeature.State) { - viewStateDelegate.switchState(state) + override fun render(state: ViewState) { + viewStateDelegate?.switchState(state) - if (state is ProfileSettingsFeature.State.Content) { + if (state is ViewState.Content) { if (state.isLoadingMagicLink) { LoadingProgressDialogFragment.newInstance() .showIfNotExists(childFragmentManager, LoadingProgressDialogFragment.TAG) } else { childFragmentManager.dismissDialogFragmentIfExists(LoadingProgressDialogFragment.TAG) } - viewBinding.settingsThemeChosenTextView.text = state.profileSettings.theme.representation + viewBinding.settingsContent.settingsThemeChosenTextView.text = + state.profileSettings.theme.getStringRepresentation(requireContext()) currentThemePosition = state.profileSettings.theme.ordinal + renderSubscription(state) } } + private fun renderSubscription( + state: ViewState.Content + ) { + state.subscriptionState?.description + ?.let(viewBinding.settingsContent.settingsSubscriptionHeader::setText) + viewBinding.settingsContent.settingsSubscriptionLinearLayout.isVisible = + state.subscriptionState != null + } + private fun sendEmailFeedback(feedbackEmailData: FeedbackEmailData) { val intent = Intent(Intent.ACTION_SENDTO) .setData(Uri.parse("mailto:")) diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/request_review/dialog/RequestReviewDialogFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/request_review/dialog/RequestReviewDialogFragment.kt new file mode 100644 index 0000000000..f468aff7a7 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/request_review/dialog/RequestReviewDialogFragment.kt @@ -0,0 +1,135 @@ +package org.hyperskill.app.android.request_review.dialog + +import android.app.Dialog +import android.content.DialogInterface +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import co.touchlab.kermit.Logger +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.play.core.ktx.launchReview +import com.google.android.play.core.ktx.requestReview +import com.google.android.play.core.review.ReviewException +import com.google.android.play.core.review.ReviewManagerFactory +import com.google.android.play.core.review.model.ReviewErrorCode +import kotlinx.coroutines.launch +import org.hyperskill.app.android.HyperskillApp +import org.hyperskill.app.android.R +import org.hyperskill.app.android.core.extensions.argument +import org.hyperskill.app.android.core.extensions.setHyperskillColors +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.android.request_review.ui.RequestReviewDialog +import org.hyperskill.app.core.view.handleActions +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature +import org.hyperskill.app.request_review.presentation.RequestReviewModalViewModel +import org.hyperskill.app.step.domain.model.StepRoute + +class RequestReviewDialogFragment : BottomSheetDialogFragment() { + + companion object { + const val TAG = "RequestUserReviewDialogFragment" + + fun newInstance(stepRoute: StepRoute): RequestReviewDialogFragment = + RequestReviewDialogFragment().apply { + this.stepRoute = stepRoute + } + } + + private var stepRoute: StepRoute by argument(StepRoute.serializer()) + + private var viewModelFactory: ViewModelProvider.Factory? = null + private val requestUserReviewViewModal: RequestReviewModalViewModel by viewModels { + requireNotNull(viewModelFactory) + } + + private val logger: Logger by lazy { + HyperskillApp.graph().loggerComponent.logger.withTag(TAG) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + injectComponent() + setStyle(STYLE_NORMAL, R.style.TopCornersRoundedBottomSheetDialog) + } + + private fun injectComponent() { + val requestReviewComponent = HyperskillApp.graph().buildPlatformRequestReviewComponent(stepRoute) + viewModelFactory = requestReviewComponent.reduxViewModelFactory + requestUserReviewViewModal.handleActions(this, onAction = ::onAction) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = + BottomSheetDialog(requireContext(), theme).also { dialog -> + dialog.setOnShowListener { + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + if (savedInstanceState == null) { + requestUserReviewViewModal.onShownEvent() + } + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + requestUserReviewViewModal.onHiddenEvent() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner)) + setContent { + HyperskillTheme { + RequestReviewDialog(viewModel = requestUserReviewViewModal) + } + } + } + + private fun onAction(action: RequestReviewModalFeature.Action.ViewAction) { + when (action) { + RequestReviewModalFeature.Action.ViewAction.Dismiss -> dismiss() + RequestReviewModalFeature.Action.ViewAction.RequestUserReview -> { + val manager = ReviewManagerFactory.create(requireContext()) + lifecycleScope.launch { + val reviewInfo = try { + manager.requestReview() + } catch (e: ReviewException) { + logger.e(e) { + val errorDescription = when (e.errorCode) { + ReviewErrorCode.NO_ERROR -> "No error" + ReviewErrorCode.PLAY_STORE_NOT_FOUND -> "Play store not found" + ReviewErrorCode.INTERNAL_ERROR -> "Internal error" + ReviewErrorCode.INVALID_REQUEST -> " Invalid request" + else -> "" + } + "Failed to launch app review. $errorDescription" + } + dismiss() + return@launch + } + manager.launchReview(requireActivity(), reviewInfo) + dismiss() + } + } + is RequestReviewModalFeature.Action.ViewAction.SubmitSupportRequest -> { + val intent = CustomTabsIntent.Builder() + .setHyperskillColors(requireContext()) + .build() + intent.launchUrl(requireActivity(), Uri.parse(action.url)) + dismiss() + } + } + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/request_review/ui/RequestReviewDialog.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/request_review/ui/RequestReviewDialog.kt new file mode 100644 index 0000000000..c826fc7ae8 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/request_review/ui/RequestReviewDialog.kt @@ -0,0 +1,172 @@ +package org.hyperskill.app.android.request_review.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.hyperskill.app.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillButton +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillOutlinedButton +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.ViewState +import org.hyperskill.app.request_review.presentation.RequestReviewModalViewModel + +@Composable +fun RequestReviewDialog(viewModel: RequestReviewModalViewModel) { + val viewState: ViewState by viewModel.state.collectAsStateWithLifecycle() + RequestReviewDialog( + viewState = viewState, + onPositiveButtonClick = viewModel::onPositiveButtonClick, + onNegativeButtonClick = viewModel::onNegativeButtonClick + ) +} + +@Composable +fun RequestReviewDialog( + viewState: ViewState, + onPositiveButtonClick: () -> Unit, + onNegativeButtonClick: () -> Unit +) { + Column { + BottomSheetDragIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 20.dp) + ) + Spacer(modifier = Modifier.height(40.dp)) + Column( + modifier = Modifier.padding(horizontal = 20.dp) + ) { + Text( + text = viewState.title, + style = MaterialTheme.typography.h4 + ) + val description = viewState.description + if (description != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = description, + style = MaterialTheme.typography.body2 + ) + } + Spacer(modifier = Modifier.height(24.dp)) + when (viewState.state) { + ViewState.State.AWAITING, ViewState.State.POSITIVE -> AwaitingButtons( + positiveButtonText = viewState.positiveButtonText, + negativeButtonText = viewState.negativeButtonText, + onPositiveButtonClick = onPositiveButtonClick, + onNegativeButtonClick = onNegativeButtonClick + ) + ViewState.State.NEGATIVE -> NegativeButtons( + positiveButtonText = viewState.positiveButtonText, + negativeButtonText = viewState.negativeButtonText, + onPositiveButtonClick = onPositiveButtonClick, + onNegativeButtonClick = onNegativeButtonClick + ) + } + Spacer(modifier = Modifier.height(20.dp)) + } + } +} + +@Composable +private fun AwaitingButtons( + positiveButtonText: String, + negativeButtonText: String, + onPositiveButtonClick: () -> Unit, + onNegativeButtonClick: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + HyperskillOutlinedButton( + onClick = onPositiveButtonClick, + modifier = Modifier.weight(1f) + ) { + Text(text = positiveButtonText) + } + HyperskillOutlinedButton( + onClick = onNegativeButtonClick, + modifier = Modifier.weight(1f) + ) { + Text( + text = negativeButtonText + ) + } + } +} + +@Composable +private fun NegativeButtons( + positiveButtonText: String, + negativeButtonText: String, + onPositiveButtonClick: () -> Unit, + onNegativeButtonClick: () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + HyperskillButton( + onClick = onPositiveButtonClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = positiveButtonText) + } + HyperskillOutlinedButton( + onClick = onNegativeButtonClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = negativeButtonText) + } + } +} + +@Composable +fun BottomSheetDragIndicator(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .requiredSize(width = 60.dp, height = 4.dp) + .clip(RoundedCornerShape(3.dp)) + .background(colorResource(id = R.color.layer_active_2)) + ) +} + +private class RequestReviewModalPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + RequestReviewPreviewDefaults.AwaitingViewState, + RequestReviewPreviewDefaults.NegativeViewState + ) +} + +@Preview +@Composable +private fun RequestReviewDialogPreview( + @PreviewParameter(RequestReviewModalPreviewProvider::class) viewState: ViewState +) { + HyperskillTheme { + RequestReviewDialog( + viewState = viewState, + onPositiveButtonClick = {}, + onNegativeButtonClick = {} + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/request_review/ui/RequestReviewPreviewDefaults.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/request_review/ui/RequestReviewPreviewDefaults.kt new file mode 100644 index 0000000000..6efd41d499 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/request_review/ui/RequestReviewPreviewDefaults.kt @@ -0,0 +1,23 @@ +package org.hyperskill.app.android.request_review.ui + +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature + +object RequestReviewPreviewDefaults { + val AwaitingViewState: RequestReviewModalFeature.ViewState + get() = RequestReviewModalFeature.ViewState( + title = "Do you enjoy Hyperskill app?", + description = null, + positiveButtonText = "\uD83D\uDC4D Yes", + negativeButtonText = "\uD83D\uDC4E No", + state = RequestReviewModalFeature.ViewState.State.AWAITING + ) + + val NegativeViewState: RequestReviewModalFeature.ViewState + get() = RequestReviewModalFeature.ViewState( + title = "Thank you", + description = "Share what you disliked to help us improve your experience.", + positiveButtonText = "Write a request", + negativeButtonText = "Maybe later", + state = RequestReviewModalFeature.ViewState.State.NEGATIVE + ) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step/view/delegate/StepDelegate.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step/view/delegate/StepDelegate.kt index b7f686dc84..ef3ec0e7bc 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step/view/delegate/StepDelegate.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step/view/delegate/StepDelegate.kt @@ -14,6 +14,7 @@ import org.hyperskill.app.android.main.view.ui.navigation.MainScreen import org.hyperskill.app.android.main.view.ui.navigation.MainScreenRouter import org.hyperskill.app.android.main.view.ui.navigation.Tabs import org.hyperskill.app.android.main.view.ui.navigation.switch +import org.hyperskill.app.android.request_review.dialog.RequestReviewDialogFragment import org.hyperskill.app.android.share_streak.fragment.ShareStreakDialogFragment import org.hyperskill.app.android.step.view.dialog.TopicPracticeCompletedBottomSheet import org.hyperskill.app.android.step.view.screen.StepScreen @@ -102,6 +103,13 @@ class StepDelegate( manager = fragment.childFragmentManager, tag = InterviewPreparationFinishedDialogFragment.TAG ) + is StepCompletionFeature.Action.ViewAction.ShowRequestUserReviewModal -> + RequestReviewDialogFragment + .newInstance(stepCompletionAction.stepRoute) + .showIfNotExists( + manager = fragment.childFragmentManager, + tag = RequestReviewDialogFragment.TAG + ) } } } diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/dialog/CompletedStepOfTheDayDialogFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/dialog/CompletedStepOfTheDayDialogFragment.kt index 3273625254..aca14d6b7f 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/dialog/CompletedStepOfTheDayDialogFragment.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/dialog/CompletedStepOfTheDayDialogFragment.kt @@ -13,25 +13,28 @@ import by.kirich1409.viewbindingdelegate.viewBinding import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.serialization.Serializable import org.hyperskill.app.android.R import org.hyperskill.app.android.core.extensions.argument import org.hyperskill.app.android.databinding.FragmentCompletedDailyStepBinding import org.hyperskill.app.step.presentation.StepFeature import org.hyperskill.app.step.presentation.StepViewModel import org.hyperskill.app.step_completion.presentation.StepCompletionFeature -import ru.nobird.android.view.base.ui.extension.argument class CompletedStepOfTheDayDialogFragment : BottomSheetDialogFragment() { + companion object { const val TAG = "CompletedStepOfTheDayDialogFragment" fun newInstance( - earnedGemsText: String, + earnedGemsText: String?, shareStreakData: StepCompletionFeature.ShareStreakData ): CompletedStepOfTheDayDialogFragment = CompletedStepOfTheDayDialogFragment().apply { - this.earnedGemsText = earnedGemsText - this.shareStreakData = shareStreakData + this.params = Params( + earnedGemsText = earnedGemsText, + shareStreakData = shareStreakData + ) } } @@ -40,11 +43,7 @@ class CompletedStepOfTheDayDialogFragment : BottomSheetDialogFragment() { private val viewBinding: FragmentCompletedDailyStepBinding by viewBinding(FragmentCompletedDailyStepBinding::bind) - private var earnedGemsText: String by argument() - - private var shareStreakData: StepCompletionFeature.ShareStreakData by argument( - StepCompletionFeature.ShareStreakData.serializer() - ) + private var params: Params by argument(Params.serializer()) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -80,12 +79,13 @@ class CompletedStepOfTheDayDialogFragment : BottomSheetDialogFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) with(viewBinding) { - completedDailyStepEarnedGemsTextView.text = earnedGemsText + completedDailyStepEarnedGemsTextView.isVisible = params.earnedGemsText != null + completedDailyStepEarnedGemsTextView.text = params.earnedGemsText completedDailyStepStreakTextView.isVisible = - shareStreakData is StepCompletionFeature.ShareStreakData.Content + params.shareStreakData is StepCompletionFeature.ShareStreakData.Content completedDailyStepStreakTextView.text = - (shareStreakData as? StepCompletionFeature.ShareStreakData.Content)?.streakText + (params.shareStreakData as? StepCompletionFeature.ShareStreakData.Content)?.streakText completedDailyStepGoBackButton.setOnClickListener { stepViewModel.onNewMessage( @@ -97,8 +97,8 @@ class CompletedStepOfTheDayDialogFragment : BottomSheetDialogFragment() { } completedDailyStepShareStreakButton.isVisible = - shareStreakData is StepCompletionFeature.ShareStreakData.Content - (shareStreakData as? StepCompletionFeature.ShareStreakData.Content)?.streak?.let { streak -> + params.shareStreakData is StepCompletionFeature.ShareStreakData.Content + (params.shareStreakData as? StepCompletionFeature.ShareStreakData.Content)?.streak?.let { streak -> completedDailyStepShareStreakButton.setOnClickListener { stepViewModel.onNewMessage( StepFeature.Message.StepCompletionMessage( @@ -118,4 +118,10 @@ class CompletedStepOfTheDayDialogFragment : BottomSheetDialogFragment() { ) ) } + + @Serializable + private data class Params( + val earnedGemsText: String?, + val shareStreakData: StepCompletionFeature.ShareStreakData + ) } \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/fragment/DefaultStepQuizFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/fragment/DefaultStepQuizFragment.kt index 52be7e8312..c3f3ae2848 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/fragment/DefaultStepQuizFragment.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/step_quiz/view/fragment/DefaultStepQuizFragment.kt @@ -31,6 +31,7 @@ import org.hyperskill.app.android.main.view.ui.navigation.MainScreen import org.hyperskill.app.android.main.view.ui.navigation.MainScreenRouter import org.hyperskill.app.android.main.view.ui.navigation.Tabs import org.hyperskill.app.android.main.view.ui.navigation.switch +import org.hyperskill.app.android.paywall.navigation.PaywallScreen import org.hyperskill.app.android.problems_limit.dialog.ProblemsLimitReachedBottomSheet import org.hyperskill.app.android.step.view.model.StepCompletionHost import org.hyperskill.app.android.step.view.model.StepCompletionView @@ -278,11 +279,15 @@ abstract class DefaultStepQuizFragment : is StepQuizFeature.Action.ViewAction.NavigateTo.StepScreen -> { requireRouter().navigateTo(StepScreen(action.stepRoute)) } + is StepQuizFeature.Action.ViewAction.NavigateTo.Paywall -> { + requireRouter().navigateTo(PaywallScreen(action.paywallTransitionSource)) + } is StepQuizFeature.Action.ViewAction.RequestResetCode -> { requestResetCodeActionPermission() } is StepQuizFeature.Action.ViewAction.ShowProblemsLimitReachedModal -> { - ProblemsLimitReachedBottomSheet.newInstance(action.modalText) + ProblemsLimitReachedBottomSheet + .newInstance(action.modalData) .showIfNotExists(childFragmentManager, ProblemsLimitReachedBottomSheet.TAG) } is StepQuizFeature.Action.ViewAction.ShowProblemOnboardingModal -> { @@ -295,6 +300,15 @@ abstract class DefaultStepQuizFragment : is StepQuizFeature.Action.ViewAction.StepQuizHintsViewAction -> { stepQuizHintsDelegate?.onAction(action.viewAction) } + StepQuizFeature.Action.ViewAction.NavigateTo.StudyPlan -> { + // TODO: ALTAPPS-807 + } + is StepQuizFeature.Action.ViewAction.CreateMagicLinkState -> { + // TODO: ALTAPPS-807 + } + is StepQuizFeature.Action.ViewAction.OpenUrl -> { + // TODO: ALTAPPS-807 + } } } diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/study_plan/fragment/StudyPlanFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/study_plan/fragment/StudyPlanFragment.kt index 76380f63e8..765af0245a 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/study_plan/fragment/StudyPlanFragment.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/study_plan/fragment/StudyPlanFragment.kt @@ -19,6 +19,7 @@ import org.hyperskill.app.android.problems_limit.view.ui.delegate.ProblemsLimitD import org.hyperskill.app.android.stage_implementation.view.dialog.UnsupportedStageBottomSheet import org.hyperskill.app.android.study_plan.delegate.LearningActivityTargetViewActionHandler import org.hyperskill.app.android.study_plan.delegate.StudyPlanWidgetDelegate +import org.hyperskill.app.android.users_questionnaire.delegate.UsersQuestionnaireCardDelegate import org.hyperskill.app.core.injection.ReduxViewModelFactory import org.hyperskill.app.study_plan.presentation.StudyPlanScreenViewModel import org.hyperskill.app.study_plan.screen.presentation.StudyPlanScreenFeature @@ -49,6 +50,7 @@ class StudyPlanFragment : private var gamificationToolbarDelegate: GamificationToolbarDelegate? = null private var problemsLimitDelegate: ProblemsLimitDelegate? = null private var studyPlanWidgetDelegate: StudyPlanWidgetDelegate? = null + private var usersQuestionnaireCardDelegate: UsersQuestionnaireCardDelegate? = null private var fragmentWasResumed = false @@ -84,6 +86,7 @@ class StudyPlanFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { initGamificationToolbarDelegate() initProblemsLimitDelegate() + initUserQuestionnaireCardDelegate() initSwipeRefresh() studyPlanWidgetDelegate?.setup(viewBinding.studyPlanRecycler, viewBinding.studyPlanError) } @@ -110,6 +113,15 @@ class StudyPlanFragment : problemsLimitDelegate?.setup() } + private fun initUserQuestionnaireCardDelegate() { + usersQuestionnaireCardDelegate = UsersQuestionnaireCardDelegate() + usersQuestionnaireCardDelegate?.setup( + viewBinding.studyPlanUserQuestionnaire, + viewLifecycleOwner, + onNewMessage = studyPlanViewModel::onNewMessage + ) + } + private fun initSwipeRefresh() { with(viewBinding.studyPlanSwipeRefresh) { setHyperskillColors() @@ -125,6 +137,7 @@ class StudyPlanFragment : gamificationToolbarDelegate = null problemsLimitDelegate?.cleanup() problemsLimitDelegate = null + usersQuestionnaireCardDelegate = null } override fun onDestroy() { @@ -138,6 +151,10 @@ class StudyPlanFragment : gamificationToolbarDelegate?.setSubtitle(state.trackTitle) problemsLimitDelegate?.render(state.problemsLimitViewState) studyPlanWidgetDelegate?.render(state.studyPlanWidgetViewState) + usersQuestionnaireCardDelegate?.render( + state.usersQuestionnaireWidgetState, + viewBinding.studyPlanUserQuestionnaire + ) } private fun renderSwipeRefresh(state: StudyPlanScreenFeature.ViewState) { @@ -170,6 +187,13 @@ class StudyPlanFragment : } } } + is StudyPlanScreenFeature.Action.ViewAction.UsersQuestionnaireWidgetViewAction -> { + usersQuestionnaireCardDelegate?.handleActions( + context = requireContext(), + activity = requireActivity(), + action = action.viewAction + ) + } } } diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topics_repetitions/view/delegate/TopicsRepetitionCardFormDelegate.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topics_repetitions/view/delegate/TopicsRepetitionCardFormDelegate.kt index fc75533fee..267c2eb413 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topics_repetitions/view/delegate/TopicsRepetitionCardFormDelegate.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/topics_repetitions/view/delegate/TopicsRepetitionCardFormDelegate.kt @@ -10,7 +10,7 @@ class TopicsRepetitionCardFormDelegate { context: Context, binding: LayoutTopicsRepetitionCardBinding, recommendedRepetitionsCount: Int, - isFreemiumEnabled: Boolean + areProblemsLimited: Boolean ) { with(binding) { topicsRepetitionBackgroundImageView.setImageResource( @@ -48,7 +48,8 @@ class TopicsRepetitionCardFormDelegate { org.hyperskill.app.R.string.good_job } ) - topicsRepetitionFreemiumBadge.isVisible = isFreemiumEnabled && recommendedRepetitionsCount > 0 + topicsRepetitionUnlimitedBadge.isVisible = + areProblemsLimited && recommendedRepetitionsCount > 0 } } } \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/delegate/UsersQuestionnaireCardDelegate.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/delegate/UsersQuestionnaireCardDelegate.kt new file mode 100644 index 0000000000..35daee6281 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/delegate/UsersQuestionnaireCardDelegate.kt @@ -0,0 +1,78 @@ +package org.hyperskill.app.android.users_questionnaire.delegate + +import android.app.Activity +import android.content.Context +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.MutableStateFlow +import org.hyperskill.app.android.core.extensions.setHyperskillColors +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.android.users_questionnaire.ui.UsersQuestionnaireWidget +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature + +class UsersQuestionnaireCardDelegate { + + private val stateFlow: MutableStateFlow = MutableStateFlow(null) + + fun setup( + composeView: ComposeView, + viewLifecycleOwner: LifecycleOwner, + onNewMessage: (UsersQuestionnaireWidgetFeature.Message) -> Unit + ) { + composeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner)) + setContent { + HyperskillTheme { + val viewState by stateFlow.collectAsStateWithLifecycle() + DisposableEffect(viewLifecycleOwner) { + onNewMessage(UsersQuestionnaireWidgetFeature.Message.ViewedEventMessage) + onDispose { + // no op + } + } + viewState?.let { actualViewState -> + UsersQuestionnaireWidget( + actualViewState, + onNewMessage = onNewMessage + ) + } + } + } + } + } + + fun render( + state: UsersQuestionnaireWidgetFeature.State, + composeView: ComposeView + ) { + composeView.isVisible = when (state) { + UsersQuestionnaireWidgetFeature.State.Idle, + UsersQuestionnaireWidgetFeature.State.Hidden -> false + UsersQuestionnaireWidgetFeature.State.Loading, + UsersQuestionnaireWidgetFeature.State.Visible -> true + } + stateFlow.value = state + } + + fun handleActions( + context: Context, + activity: Activity, + action: UsersQuestionnaireWidgetFeature.Action.ViewAction + ) { + when (action) { + is UsersQuestionnaireWidgetFeature.Action.ViewAction.ShowUsersQuestionnaire -> { + val intent = CustomTabsIntent.Builder() + .setHyperskillColors(context) + .build() + intent.launchUrl(activity, Uri.parse(action.url)) + } + } + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/fragment/UsersQuestionnaireOnboardingFragment.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/fragment/UsersQuestionnaireOnboardingFragment.kt new file mode 100644 index 0000000000..7db581339e --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/fragment/UsersQuestionnaireOnboardingFragment.kt @@ -0,0 +1,69 @@ +package org.hyperskill.app.android.users_questionnaire.onboarding.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.snackbar.Snackbar +import org.hyperskill.app.android.HyperskillApp +import org.hyperskill.app.android.core.view.ui.navigation.requireAppRouter +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.android.users_questionnaire.onboarding.ui.UsersQuestionnaireOnboardingScreen +import org.hyperskill.app.core.view.handleActions +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Action.ViewAction +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingViewModel + +class UsersQuestionnaireOnboardingFragment : Fragment() { + companion object { + const val USERS_QUESTIONNAIRE_ONBOARDING_FINISHED = "USERS_QUESTIONNAIRE_ONBOARDING_FINISHED" + fun newInstance(): UsersQuestionnaireOnboardingFragment = + UsersQuestionnaireOnboardingFragment() + } + + private var viewModelFactory: ViewModelProvider.Factory? = null + private val usersQuestionnaireOnboardingViewModel: UsersQuestionnaireOnboardingViewModel by viewModels { + requireNotNull(viewModelFactory) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + injectComponent() + usersQuestionnaireOnboardingViewModel.handleActions(this, onAction = ::onAction) + } + + private fun injectComponent() { + val platformUsersQuestionnaireOnboardingComponent = + HyperskillApp.graph().buildPlatformUsersQuestionnaireOnboardingComponent() + viewModelFactory = platformUsersQuestionnaireOnboardingComponent.reduxViewModelFactory + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner)) + setContent { + HyperskillTheme { + UsersQuestionnaireOnboardingScreen(viewModel = usersQuestionnaireOnboardingViewModel) + } + } + } + + private fun onAction(action: ViewAction) { + when (action) { + ViewAction.CompleteUsersQuestionnaireOnboarding -> { + requireAppRouter().sendResult(USERS_QUESTIONNAIRE_ONBOARDING_FINISHED, Any()) + } + is ViewAction.ShowSendSuccessMessage -> { + Snackbar.make(requireView(), action.message, Snackbar.LENGTH_SHORT).show() + } + } + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/navigation/UsersQuestionnaireOnboardingScreen.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/navigation/UsersQuestionnaireOnboardingScreen.kt new file mode 100644 index 0000000000..97486bbe39 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/navigation/UsersQuestionnaireOnboardingScreen.kt @@ -0,0 +1,11 @@ +package org.hyperskill.app.android.users_questionnaire.onboarding.navigation + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentFactory +import com.github.terrakok.cicerone.androidx.FragmentScreen +import org.hyperskill.app.android.users_questionnaire.onboarding.fragment.UsersQuestionnaireOnboardingFragment + +object UsersQuestionnaireOnboardingScreen : FragmentScreen { + override fun createFragment(factory: FragmentFactory): Fragment = + UsersQuestionnaireOnboardingFragment.newInstance() +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/ui/UsersQuestionnaireOnboardingDefaults.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/ui/UsersQuestionnaireOnboardingDefaults.kt new file mode 100644 index 0000000000..a214007985 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/ui/UsersQuestionnaireOnboardingDefaults.kt @@ -0,0 +1,13 @@ +package org.hyperskill.app.android.users_questionnaire.onboarding.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.dp + +object UsersQuestionnaireOnboardingDefaults { + val ContentPadding: PaddingValues = PaddingValues( + top = 20.dp, + start = 20.dp, + end = 20.dp, + bottom = 8.dp + ) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/ui/UsersQuestionnaireOnboardingPreviewDefault.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/ui/UsersQuestionnaireOnboardingPreviewDefault.kt new file mode 100644 index 0000000000..712ad5e188 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/ui/UsersQuestionnaireOnboardingPreviewDefault.kt @@ -0,0 +1,54 @@ +package org.hyperskill.app.android.users_questionnaire.onboarding.ui + +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature + +object UsersQuestionnaireOnboardingPreviewDefault { + + private enum class SelectedChoice { + NONE, + FIRST, + LAST + } + + private fun getViewState( + selectedChoice: SelectedChoice, + isSendButtonEnabled: Boolean + ) = + UsersQuestionnaireOnboardingFeature.ViewState( + title = "How did you hear about Hyperskill?", + choices = listOf( + "Google", + "Youtube", + "Instagram", + "Tiktok", + "News", + "Friends", + "Other" + ), + selectedChoice = when (selectedChoice) { + SelectedChoice.NONE -> null + SelectedChoice.FIRST -> "Google" + SelectedChoice.LAST -> "Other" + }, + textInputValue = when (selectedChoice) { + SelectedChoice.NONE -> null + SelectedChoice.FIRST, + SelectedChoice.LAST -> "example text" + }, + isTextInputVisible = selectedChoice == SelectedChoice.LAST, + isSendButtonEnabled = when (selectedChoice) { + SelectedChoice.NONE -> false + SelectedChoice.FIRST, + SelectedChoice.LAST -> isSendButtonEnabled + } + ) + + fun getUnselectedViewState(): UsersQuestionnaireOnboardingFeature.ViewState = + getViewState(SelectedChoice.NONE, false) + + fun getFirstOptionSelectedViewState(): UsersQuestionnaireOnboardingFeature.ViewState = + getViewState(SelectedChoice.FIRST, true) + + fun getOtherOptionSelectedViewState(isSendButtonEnabled: Boolean): UsersQuestionnaireOnboardingFeature.ViewState = + getViewState(SelectedChoice.LAST, isSendButtonEnabled) +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/ui/UsersQuestionnaireOnboardingScreen.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/ui/UsersQuestionnaireOnboardingScreen.kt new file mode 100644 index 0000000000..c4df6796e9 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/ui/UsersQuestionnaireOnboardingScreen.kt @@ -0,0 +1,149 @@ +package org.hyperskill.app.android.users_questionnaire.onboarding.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.hyperskill.app.R +import org.hyperskill.app.android.core.extensions.plus +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillButton +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTextButton +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.ViewState +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingViewModel + +@Composable +fun UsersQuestionnaireOnboardingScreen(viewModel: UsersQuestionnaireOnboardingViewModel) { + DisposableEffect(viewModel) { + viewModel.onNewMessage( + UsersQuestionnaireOnboardingFeature.Message.ViewedEventMessage + ) + onDispose { + // no op + } + } + val viewState by viewModel.state.collectAsStateWithLifecycle() + UsersQuestionnaireOnboardingScreen( + viewState = viewState, + onChoiceClicked = viewModel::onChoiceClicked, + onTextInputChanged = viewModel::onTextInputChanged, + onSendClick = viewModel::onSendButtonClick, + onSkipClick = viewModel::onSkipButtonClick + ) +} + +@Composable +fun UsersQuestionnaireOnboardingScreen( + viewState: ViewState, + onChoiceClicked: (String) -> Unit, + onTextInputChanged: (String) -> Unit, + onSendClick: () -> Unit, + onSkipClick: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold { padding -> + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(padding + UsersQuestionnaireOnboardingDefaults.ContentPadding) + ) { + Text( + text = viewState.title, + style = MaterialTheme.typography.h5, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(20.dp)) + UsersQuestionnaireOptionsList( + choices = viewState.choices, + selectedChoice = viewState.selectedChoice, + textInputValue = viewState.textInputValue, + isTextInputVisible = viewState.isTextInputVisible, + onChoiceClicked = onChoiceClicked, + onTextInputChanged = onTextInputChanged, + onDoneClick = onSendClick + ) + Spacer(modifier = Modifier.height(32.dp)) + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + HyperskillButton( + onClick = onSendClick, + enabled = viewState.isSendButtonEnabled, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.users_questionnaire_onboarding_send_button_text)) + } + HyperskillTextButton( + onClick = onSkipClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.users_questionnaire_onboarding_skip_button_text)) + } + } + } + } +} + +private class UsersQuestionnaireOnboardingPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + UsersQuestionnaireOnboardingPreviewDefault.getUnselectedViewState(), + UsersQuestionnaireOnboardingPreviewDefault.getFirstOptionSelectedViewState(), + UsersQuestionnaireOnboardingPreviewDefault.getOtherOptionSelectedViewState(false), + UsersQuestionnaireOnboardingPreviewDefault.getOtherOptionSelectedViewState(true) + ) +} + +@Preview(device = "id:pixel_3", showSystemUi = true) +@Composable +private fun UsersQuestionnaireOnboardingScreenPreview( + @PreviewParameter(UsersQuestionnaireOnboardingPreviewParameterProvider::class) + viewState: ViewState +) { + HyperskillTheme { + UsersQuestionnaireOnboardingScreen( + viewState = viewState, + onChoiceClicked = {}, + onTextInputChanged = {}, + onSendClick = {}, + onSkipClick = {} + ) + } +} + +@Preview(device = "id:Nexus S", showSystemUi = true) +@Composable +private fun UsersQuestionnaireOnboardingScreenPreviewSmallDevice( + @PreviewParameter(UsersQuestionnaireOnboardingPreviewParameterProvider::class) + viewState: ViewState +) { + HyperskillTheme { + UsersQuestionnaireOnboardingScreen( + viewState = viewState, + onChoiceClicked = {}, + onTextInputChanged = {}, + onSendClick = {}, + onSkipClick = {} + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/ui/UsersQuestionnaireOptionsList.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/ui/UsersQuestionnaireOptionsList.kt new file mode 100644 index 0000000000..70a2723271 --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/onboarding/ui/UsersQuestionnaireOptionsList.kt @@ -0,0 +1,80 @@ +package org.hyperskill.app.android.users_questionnaire.onboarding.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.RadioButton +import androidx.compose.material.RadioButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.hyperskill.app.R + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun UsersQuestionnaireOptionsList( + choices: List, + selectedChoice: String?, + textInputValue: String?, + isTextInputVisible: Boolean, + onChoiceClicked: (String) -> Unit, + onTextInputChanged: (String) -> Unit, + onDoneClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + ) { + choices.forEach { choice -> + key(choice) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(modifier = Modifier.requiredSize(24.dp)) { + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + RadioButton( + selected = choice == selectedChoice, + onClick = { onChoiceClicked(choice) }, + colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary), + modifier = Modifier.align(Alignment.Center) + ) + } + } + Text( + text = choice, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + } + } + AnimatedVisibility(visible = isTextInputVisible) { + OutlinedTextField( + value = textInputValue ?: "", + onValueChange = onTextInputChanged, + placeholder = { + Text(text = stringResource(id = R.string.users_questionnaire_onboarding_text_input_placeholder)) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onDoneClick() }), + modifier = Modifier.fillMaxWidth() + ) + } + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/ui/UsersQuestionnaireWidget.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/ui/UsersQuestionnaireWidget.kt new file mode 100644 index 0000000000..fd28735d0d --- /dev/null +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/users_questionnaire/ui/UsersQuestionnaireWidget.kt @@ -0,0 +1,145 @@ +package org.hyperskill.app.android.users_questionnaire.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import org.hyperskill.app.android.R +import org.hyperskill.app.android.core.view.ui.widget.compose.HyperskillTheme +import org.hyperskill.app.android.core.view.ui.widget.compose.ShimmerLoading +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature +import org.hyperskill.app.R as SharedR + +object UsersQuestionnaireWidgetDefaults { + + val shape: Shape + @Composable + get() = RoundedCornerShape(dimensionResource(id = R.dimen.corner_radius)) +} + +@Composable +fun UsersQuestionnaireWidget( + viewState: UsersQuestionnaireWidgetFeature.State, + onNewMessage: (UsersQuestionnaireWidgetFeature.Message) -> Unit, + modifier: Modifier = Modifier +) { + when (viewState) { + UsersQuestionnaireWidgetFeature.State.Idle, + UsersQuestionnaireWidgetFeature.State.Hidden -> { + // no op + } + UsersQuestionnaireWidgetFeature.State.Loading -> { + ShimmerLoading( + modifier = modifier + .fillMaxWidth() + .height(64.dp) + ) + } + UsersQuestionnaireWidgetFeature.State.Visible -> { + UsersQuestionnaireWidgetContent(onNewMessage, modifier) + } + } +} + +@Composable +private fun UsersQuestionnaireWidgetContent( + onNewMessage: (UsersQuestionnaireWidgetFeature.Message) -> Unit, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .clip(UsersQuestionnaireWidgetDefaults.shape) + .border( + width = 1.dp, + color = colorResource(id = SharedR.color.color_on_surface_alpha_9), + shape = UsersQuestionnaireWidgetDefaults.shape + ) + .background( + Brush.verticalGradient( + listOf( + Color(0xFF7AB7FE), + Color(0xFF6C63FF) + ) + ) + ) + .clickable { + onNewMessage(UsersQuestionnaireWidgetFeature.Message.WidgetClicked) + } + .padding( + horizontal = 20.dp, + vertical = 24.dp + ) + ) { + Text( + text = stringResource(id = SharedR.string.users_questionnaire_widget_title), + color = colorResource(id = SharedR.color.text_on_color), + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ) + Box( + modifier = Modifier + .requiredSize(24.dp) + .align(Alignment.CenterVertically) + .clickable { + onNewMessage(UsersQuestionnaireWidgetFeature.Message.CloseClicked) + } + ) { + Image( + painter = painterResource(id = R.drawable.ic_user_questionnaire_close), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + } + } +} + +private class UsersQuestionnairePreviewParameterProvider : + PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + UsersQuestionnaireWidgetFeature.State.Loading, + UsersQuestionnaireWidgetFeature.State.Visible + ) +} + +@Preview +@Composable +private fun UsersQuestionnaireWidgetPreview( + @PreviewParameter(UsersQuestionnairePreviewParameterProvider::class) + state: UsersQuestionnaireWidgetFeature.State +) { + HyperskillTheme { + UsersQuestionnaireWidget( + viewState = state, + onNewMessage = {}, + modifier = Modifier.requiredWidth(320.dp) + ) + } +} \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/drawable-hdpi/img_paywall.webp b/androidHyperskillApp/src/main/res/drawable-hdpi/img_paywall.webp new file mode 100644 index 0000000000..1482d2f89f Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-hdpi/img_paywall.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-mdpi/img_paywall.webp b/androidHyperskillApp/src/main/res/drawable-mdpi/img_paywall.webp new file mode 100644 index 0000000000..802ff4e406 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-mdpi/img_paywall.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xhdpi/img_paywall.webp b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_paywall.webp new file mode 100644 index 0000000000..0b77ce147c Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xhdpi/img_paywall.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_paywall.webp b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_paywall.webp new file mode 100644 index 0000000000..5038d34de7 Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xxhdpi/img_paywall.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_paywall.webp b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_paywall.webp new file mode 100644 index 0000000000..28dc6cb8fb Binary files /dev/null and b/androidHyperskillApp/src/main/res/drawable-xxxhdpi/img_paywall.webp differ diff --git a/androidHyperskillApp/src/main/res/drawable/ic_paywall_option.xml b/androidHyperskillApp/src/main/res/drawable/ic_paywall_option.xml new file mode 100644 index 0000000000..f1b0946aad --- /dev/null +++ b/androidHyperskillApp/src/main/res/drawable/ic_paywall_option.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidHyperskillApp/src/main/res/drawable/ic_user_questionnaire_close.xml b/androidHyperskillApp/src/main/res/drawable/ic_user_questionnaire_close.xml new file mode 100644 index 0000000000..b6683b2f3b --- /dev/null +++ b/androidHyperskillApp/src/main/res/drawable/ic_user_questionnaire_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/androidHyperskillApp/src/main/res/layout/fragment_problems_limit_reached.xml b/androidHyperskillApp/src/main/res/layout/fragment_problems_limit_reached.xml index 11be520190..f100f009ff 100644 --- a/androidHyperskillApp/src/main/res/layout/fragment_problems_limit_reached.xml +++ b/androidHyperskillApp/src/main/res/layout/fragment_problems_limit_reached.xml @@ -1,10 +1,11 @@ - @@ -13,6 +14,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_bottom_sheet_swipe_indicator" + android:layout_gravity="center_horizontal" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" @@ -33,16 +35,13 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/layout/fragment_profile_settings.xml b/androidHyperskillApp/src/main/res/layout/fragment_profile_settings.xml index 702b9d2207..d57251c433 100644 --- a/androidHyperskillApp/src/main/res/layout/fragment_profile_settings.xml +++ b/androidHyperskillApp/src/main/res/layout/fragment_profile_settings.xml @@ -1,291 +1,37 @@ - + android:layout_height="match_parent" + android:orientation="vertical"> - + android:layout_height="wrap_content" + android:elevation="?appBarElevation"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - + android:layout_gravity="center" + android:visibility="gone"/> - + - \ No newline at end of file + \ No newline at end of file diff --git a/androidHyperskillApp/src/main/res/layout/fragment_study_plan.xml b/androidHyperskillApp/src/main/res/layout/fragment_study_plan.xml index 9ab2df38c2..b35c3e3529 100644 --- a/androidHyperskillApp/src/main/res/layout/fragment_study_plan.xml +++ b/androidHyperskillApp/src/main/res/layout/fragment_study_plan.xml @@ -25,12 +25,19 @@ android:orientation="vertical"> + android:id="@+id/studyPlanProblemsLimit" + layout="@layout/layout_problems_limit" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="28dp" + android:layout_marginHorizontal="20dp"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/androidHyperskillApp/src/test/java/org/hyperskill/app/android/util/AppFeatureStateSerializationTest.kt b/androidHyperskillApp/src/test/java/org/hyperskill/app/android/util/AppFeatureStateSerializationTest.kt index 6fa6d486a0..a3d18f5a01 100644 --- a/androidHyperskillApp/src/test/java/org/hyperskill/app/android/util/AppFeatureStateSerializationTest.kt +++ b/androidHyperskillApp/src/test/java/org/hyperskill/app/android/util/AppFeatureStateSerializationTest.kt @@ -15,7 +15,11 @@ class AppFeatureStateSerializationTest { AppFeature.State.Loading::class -> AppFeature.State.Loading AppFeature.State.NetworkError::class -> AppFeature.State.NetworkError AppFeature.State.Ready::class -> - AppFeature.State.Ready(isAuthorized = true, isMobileLeaderboardsEnabled = true) + AppFeature.State.Ready( + isAuthorized = true, + isMobileLeaderboardsEnabled = true, + isMobileOnlySubscriptionEnabled = true + ) else -> throw IllegalStateException("Unknown state class: $stateClass. Please add it to the test.") } val json = Json.encodeToString(AppFeature.State.serializer(), state) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 876c922b22..b22ed732fd 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -4,4 +4,4 @@ plugins { repositories { mavenCentral() -} +} \ No newline at end of file diff --git a/gradle/app.versions.toml b/gradle/app.versions.toml index b97545928e..bc12a6d0cc 100644 --- a/gradle/app.versions.toml +++ b/gradle/app.versions.toml @@ -2,5 +2,5 @@ minSdk = '24' targetSdk = '33' compileSdk = '33' -versionName = '1.49.1' -versionCode = '312' \ No newline at end of file +versionName = '1.50' +versionCode = '343' \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f0191c8c00..74234d85fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ kotlin = '1.8.22' kotlinCoroutines = '1.7.2' ktor = '2.3.3' mokoResources = "0.23.0" -mokoKswift = "0.6.1" +mokoKswift = "0.7.0" ktlintRules = '1.0.0' adapters = '1.1.1' sentry = '7.1.0' @@ -17,6 +17,9 @@ coil = '2.2.0' lottie = '6.1.0' kermit = '2.0.0-RC4' androidxBrowser = "1.5.0" +revenueCat = "7.4.0" +googlePlayReview = "2.0.1" + kotlinCompilerExtension = "1.4.8" composeBom = "2023.06.01" @@ -59,7 +62,7 @@ kit-ui-adapters = { module = "ru.nobird.android.ui:adapters", version.ref = "ada multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version = "0.8.1" } -kermit-common = { module = "co.touchlab:kermit", version.ref = "kermit" } +kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } android-ui-material = { module = "com.google.android.material:material", version = "1.4.0" } android-ui-appcompat = { module = "androidx.appcompat:appcompat", version = "1.6.1" } @@ -82,6 +85,10 @@ plugin-googleServises = { module = "com.google.gms:google-services", version = " gms-play-services = { module = "com.google.android.gms:play-services-base", version = "18.1.0" } gms-play-login = { module = "com.google.android.gms:play-services-auth", version = "20.4.0" } +google-play-review = { module = "com.google.android.play:review-ktx", version.ref = "googlePlayReview"} + +revenuecat = { module = "com.revenuecat.purchases:purchases", version.ref = "revenueCat" } + firebase-bom = { module = "com.google.firebase:firebase-bom", version = "31.2.2" } firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" } diff --git a/iosHyperskillApp/.ruby-version b/iosHyperskillApp/.ruby-version index fd2a01863f..15a2799817 100644 --- a/iosHyperskillApp/.ruby-version +++ b/iosHyperskillApp/.ruby-version @@ -1 +1 @@ -3.1.0 +3.3.0 diff --git a/iosHyperskillApp/.swiftlint.yml b/iosHyperskillApp/.swiftlint.yml index 3097da7927..983ba93b7d 100644 --- a/iosHyperskillApp/.swiftlint.yml +++ b/iosHyperskillApp/.swiftlint.yml @@ -2,346 +2,127 @@ warning_threshold: 15 indentation: 4 -excluded: - - iosHyperskillApp/Sources/Frameworks/sharedSwift/Hyperskill-Mobile_shared.swift - -analyzer_rules: - - explicit_self - - unused_declaration - - unused_import - -only_rules: +disabled_rules: + - redundant_discardable_let + - function_body_length + - inclusive_language + - cyclomatic_complexity + - type_body_length + - superfluous_disable_command + +opt_in_rules: + - empty_count + - array_init - attributes - - block_based_kvo - - class_delegate_protocol - - closing_brace - closure_end_indentation - closure_spacing - collection_alignment - - colon - - comma - - compiler_protocol_init - - computed_accessors_order + - comma_inheritance - conditional_returns_on_newline - contains_over_filter_count - contains_over_filter_is_empty - contains_over_first_not_nil - contains_over_range_nil_comparison - - control_statement - convenience_type - - deployment_target - discarded_notification_center_observer - - discouraged_direct_init + - discouraged_assert - discouraged_none_name - discouraged_object_literal - - duplicate_enum_cases - - duplicate_imports - - duplicated_key_in_dictionary_literal - - dynamic_inline + - discouraged_optional_boolean - empty_collection_literal - empty_count - - empty_enum_arguments - - empty_parameters - - empty_parentheses_with_trailing_closure - empty_string - - empty_xctest_method - enum_case_associated_values_count - fallthrough - fatal_error_message - file_header - - file_length - file_name_no_space + - final_test_case - first_where - flatmap_over_map_reduce - - for_where - - force_cast - - force_try - force_unwrapping - - generic_type_name - identical_operands - - identifier_name - - implicit_getter - implicit_return - implicitly_unwrapped_optional - inert_defer - - is_disjoint - joined_default_parameter - - large_tuple - last_where - - leading_whitespace - - legacy_cggeometry_functions - - legacy_constant - - legacy_constructor - - legacy_hashing - legacy_multiple - - legacy_nsgeometry_functions - - legacy_random - - line_length + - let_var_whitespace - literal_expression_end_indentation + - local_doc_comment - lower_acl_than_parent - - mark - modifier_order - multiline_arguments - multiline_arguments_brackets - multiline_literal_brackets - multiline_parameters - multiline_parameters_brackets - - multiple_closures_with_trailing_closure - - nesting - - nimble_operator - - no_space_in_method_call - nslocalizedstring_key - - nsobject_prefer_isequal - - number_separator - - object_literal - - opening_brace - operator_usage_whitespace - - operator_whitespace - optional_enum_case_matching - overridden_super_call - override_in_extension - # - prohibited_nan_comparison - - prefer_self_type_over_type_of_self - - prefixed_toplevel_constant - - private_over_fileprivate - - private_unit_test + - prefer_zero_over_explicit_init - prohibited_interface_builder - prohibited_super_call - - protocol_property_accessors_order - - quick_discouraged_call - - quick_discouraged_focused_test - - quick_discouraged_pending_test - - reduce_boolean + - raw_value_for_camel_cased_codable_enum - reduce_into - # - redundant_discardable_let - redundant_nil_coalescing - - redundant_objc_attribute - - redundant_optional_initialization - - redundant_set_access_control - - redundant_string_enum_value + - redundant_self_in_closure - redundant_type_annotation - - redundant_void_return - # - return_value_from_void_function - - return_arrow_whitespace - - self_in_property_initialization - - shorthand_operator - - single_test_class + - shorthand_optional_binding - sorted_first_last - sorted_imports - - statement_position - static_operator - - strong_iboutlet - - switch_case_alignment - switch_case_on_newline - - syntactic_sugar - toggle_bool - - trailing_comma - - trailing_newline - - trailing_semicolon - - trailing_whitespace - # - tuple_pattern - - type_name - unavailable_function - - unneeded_break_in_switch + - unhandled_throwing_task - unowned_variable_capture - unused_capture_list - - unused_closure_parameter - - unused_control_flow_label - - unused_enumerated - - unused_optional_binding - - vertical_whitespace - vertical_whitespace_closing_braces - vertical_whitespace_opening_braces - # - void_function_in_ternary - - void_return - weak_delegate - yoda_condition +analyzer_rules: + - unused_declaration + - unused_import + +excluded: + - iosHyperskillApp/Sources/Frameworks/sharedSwift/Hyperskill-Mobile_shared.swift + - iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/**/*.swift + # settings attributes: severity: error attributes_with_arguments_always_on_line_above: false -block_based_kvo: - severity: error - -class_delegate_protocol: - severity: error - -closing_brace: - severity: error - -closure_spacing: - severity: error - -colon: - severity: error - -comma: - severity: error - -compiler_protocol_init: - severity: error - -computed_accessors_order: - severity: error - order: get_set - -conditional_returns_on_newline: - severity: error - -contains_over_filter_count: - severity: error - -contains_over_filter_is_empty: - severity: error - -contains_over_first_not_nil: - severity: error - -contains_over_range_nil_comparison: - severity: error - -control_statement: - severity: error - -convenience_type: - severity: error - -deployment_target: - severity: error - -discarded_notification_center_observer: - severity: error - -discouraged_direct_init: - severity: error - -discouraged_none_name: - severity: warning - -discouraged_object_literal: - severity: error - -duplicate_enum_cases: - severity: error - -duplicate_imports: - severity: error - -duplicated_key_in_dictionary_literal: - severity: error - -dynamic_inline: - severity: error - -empty_collection_literal: - severity: error - -empty_count: - severity: error - -empty_enum_arguments: - severity: error - -empty_parameters: - severity: error - -empty_parentheses_with_trailing_closure: - severity: error - -empty_string: - severity: error - -empty_xctest_method: - severity: error - -enum_case_associated_values_count: - warning: 4 - error: 5 - -explicit_self: - severity: error - -fallthrough: - severity: error - -file_header: - forbidden_pattern: "^//[^/]|/\\*[^*]" - -file_length: - warning: 400 - error: 400 - ignore_comment_only_lines: true - -file_name_no_space: - severity: error - -first_where: - severity: error - -flatmap_over_map_reduce: - severity: error - -for_where: - severity: error - -force_cast: - severity: error - -force_try: - severity: error - -force_unwrapping: - severity: error - -identical_operands: - severity: error - -generic_type_name: - severity: error - identifier_name: severity: error - excluded: ["i", "j", "x", "y", "z", "id", "to", "vk", "h1", "h2", "h3", "r", "g", "b", "no", "ok", "c", "cs", "go", "js"] + excluded: + ["i", "j", "x", "y", "z", "id", "to", "vk", "h1", "h2", "h3", "r", "g", "b", "no", "ok", "c", "cs", "go", "js"] max_length: 64 -implicit_getter: - severity: error - -implicit_return: - severity: error - -implicitly_unwrapped_optional: - severity: error - -is_disjoint: - severity: error - large_tuple: warning: 3 error: 3 -last_where: - severity: error - -legacy_cggeometry_functions: - severity: error - -legacy_constant: - severity: error - -legacy_constructor: - severity: error - -legacy_hashing: - severity: error +type_name: + min_length: 3 + max_length: + warning: 64 + error: 64 + allowed_symbols: ["_"] -legacy_multiple: - severity: error +file_length: + warning: 400 + error: 400 + ignore_comment_only_lines: true -legacy_nsgeometry_functions: - severity: error +file_header: + forbidden_pattern: "^//[^/]|/\\*[^*]" line_length: warning: 120 @@ -350,125 +131,9 @@ line_length: ignores_comments: true ignores_interpolated_strings: true -lower_acl_than_parent: - severity: error - -mark: - severity: error - -modifier_order: - severity: error - -multiline_arguments: - severity: error - first_argument_location: next_line - -multiline_arguments_brackets: - severity: error - -multiline_literal_brackets: - severity: error - -multiline_parameters: - severity: error - -multiline_parameters_brackets: - severity: error - -multiple_closures_with_trailing_closure: - severity: error - nesting: - # for dataflow type_level: 3 -number_separator: - minimum_length: 6 - -object_literal: - severity: error - image_literal: false - color_literal: false - -opening_brace: - severity: error - -operator_usage_whitespace: - severity: error - -operator_whitespace: - severity: error - -optional_enum_case_matching: - severity: error - -overridden_super_call: - severity: error - -prefer_self_type_over_type_of_self: - severity: error - -prefixed_toplevel_constant: - severity: error - -prohibited_super_call: - severity: error - -prohibited_interface_builder: - severity: error - -reduce_boolean: - severity: error - -redundant_set_access_control: - severity: error - -redundant_type_annotation: - severity: error - -self_in_property_initialization: - severity: error - -statement_position: - severity: error - -static_operator: - severity: error - -switch_case_alignment: - severity: error - -switch_case_on_newline: - severity: error - -syntactic_sugar: - severity: error - -trailing_comma: - severity: error - -trailing_newline: - severity: error - -trailing_semicolon: - severity: error - -# tuple_pattern: -# severity: error - -type_name: - severity: error - max_length: 64 - -unneeded_break_in_switch: - severity: error - -unowned_variable_capture: - severity: error - -unused_enumerated: - severity: error - unused_import: require_explicit_imports: true allowed_transitive_imports: @@ -477,18 +142,3 @@ unused_import: - CoreFoundation - Darwin - ObjectiveC - -unused_optional_binding: - severity: error - -vertical_whitespace_closing_braces: - severity: error - -vertical_whitespace_opening_braces: - severity: error - -void_return: - severity: error - -weak_delegate: - severity: error diff --git a/iosHyperskillApp/Gemfile b/iosHyperskillApp/Gemfile index 35b10d4c11..fad1d3f1c7 100644 --- a/iosHyperskillApp/Gemfile +++ b/iosHyperskillApp/Gemfile @@ -1,8 +1,8 @@ source "https://rubygems.org" -ruby "3.1.0" +ruby "3.3.0" gem "fastlane", "2.219.0" -gem "cocoapods", "1.14.3" +gem "cocoapods", "1.15.2" gem "generamba", git: "https://github.com/ivan-magda/Generamba.git", branch: "develop" eval_gemfile("fastlane/Pluginfile") \ No newline at end of file diff --git a/iosHyperskillApp/Gemfile.lock b/iosHyperskillApp/Gemfile.lock index ba046f3905..34af8071bf 100644 --- a/iosHyperskillApp/Gemfile.lock +++ b/iosHyperskillApp/Gemfile.lock @@ -16,7 +16,7 @@ GEM specs: CFPropertyList (3.0.6) rexml - activesupport (7.1.2) + activesupport (7.1.3) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -34,29 +34,29 @@ GEM artifactory (3.0.15) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.877.0) - aws-sdk-core (3.190.1) + aws-partitions (1.883.0) + aws-sdk-core (3.191.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.8) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.75.0) - aws-sdk-core (~> 3, >= 3.188.0) + aws-sdk-kms (1.77.0) + aws-sdk-core (~> 3, >= 3.191.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.142.0) - aws-sdk-core (~> 3, >= 3.189.0) + aws-sdk-s3 (1.143.0) + aws-sdk-core (~> 3, >= 3.191.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.8) aws-sigv4 (1.8.0) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) - bigdecimal (3.1.5) + bigdecimal (3.1.6) claide (1.1.0) - cocoapods (1.14.3) + cocoapods (1.15.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.14.3) + cocoapods-core (= 1.15.2) cocoapods-deintegrate (>= 1.0.3, < 2.0) cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) @@ -71,7 +71,7 @@ GEM nap (~> 1.0) ruby-macho (>= 2.3.0, < 3.0) xcodeproj (>= 1.23.0, < 2.0) - cocoapods-core (1.14.3) + cocoapods-core (1.15.2) activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -94,12 +94,12 @@ GEM colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) - concurrent-ruby (1.2.2) + concurrent-ruby (1.2.3) connection_pool (2.4.1) declarative (0.0.20) digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) - domain_name (0.6.20231109) + domain_name (0.6.20240107) dotenv (2.8.1) drb (2.2.0) ruby2_keywords @@ -178,10 +178,10 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) - fastlane-plugin-firebase_app_distribution (0.8.0) + fastlane-plugin-firebase_app_distribution (0.9.0) google-apis-firebaseappdistribution_v1 (~> 0.3.0) google-apis-firebaseappdistribution_v1alpha (~> 0.2.0) - fastlane-plugin-sentry (1.17.0) + fastlane-plugin-sentry (1.19.0) os (~> 1.1, >= 1.1.4) ffi (1.16.3) fourflusher (2.3.1) @@ -192,7 +192,7 @@ GEM rchardet (~> 1.8) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.2) + google-apis-core (0.11.3) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -200,7 +200,6 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.a) rexml - webrick google-apis-firebaseappdistribution_v1 (0.3.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-firebaseappdistribution_v1alpha (0.2.0) @@ -209,7 +208,7 @@ GEM google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.29.0) + google-apis-storage_v1 (0.31.0) google-apis-core (>= 0.11.0, < 2.a) google-cloud-core (1.6.1) google-cloud-env (>= 1.0, < 3.a) @@ -217,11 +216,11 @@ GEM google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) google-cloud-errors (1.3.1) - google-cloud-storage (1.45.0) + google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.29.0) + google-apis-storage_v1 (~> 0.31.0) google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) @@ -243,10 +242,10 @@ GEM liquid (4.0.4) mini_magick (4.12.0) mini_mime (1.1.5) - minitest (5.20.0) + minitest (5.22.0) molinillo (0.8.0) multi_json (1.15.0) - multipart-post (2.3.0) + multipart-post (2.4.0) mutex_m (0.2.0) nanaimo (0.3.0) nap (1.1.0) @@ -269,7 +268,7 @@ GEM ruby2_keywords (0.0.5) rubyzip (2.3.2) security (0.1.3) - signet (0.18.0) + signet (0.19.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) @@ -292,9 +291,8 @@ GEM concurrent-ruby (~> 1.0) uber (0.1.0) unicode-display_width (2.5.0) - webrick (1.8.1) word_wrap (1.0.0) - xcodeproj (1.23.0) + xcodeproj (1.24.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) @@ -308,19 +306,21 @@ GEM PLATFORMS arm64-darwin-22 + arm64-darwin-23 x86_64-darwin-19 + x86_64-darwin-20 x86_64-darwin-22 x86_64-linux DEPENDENCIES - cocoapods (= 1.14.3) + cocoapods (= 1.15.2) fastlane (= 2.219.0) fastlane-plugin-firebase_app_distribution fastlane-plugin-sentry generamba! RUBY VERSION - ruby 3.1.0p0 + ruby 3.3.0p0 BUNDLED WITH - 2.4.4 + 2.5.5 diff --git a/iosHyperskillApp/NotificationServiceExtension/Info.plist b/iosHyperskillApp/NotificationServiceExtension/Info.plist index 75c6e6e369..0d6586ff92 100644 --- a/iosHyperskillApp/NotificationServiceExtension/Info.plist +++ b/iosHyperskillApp/NotificationServiceExtension/Info.plist @@ -9,9 +9,9 @@ CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleVersion - 306 + 349 CFBundleShortVersionString - 1.49.1 + 1.50 CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleExecutable diff --git a/iosHyperskillApp/Podfile b/iosHyperskillApp/Podfile index cc06ccbe88..656934d3e2 100644 --- a/iosHyperskillApp/Podfile +++ b/iosHyperskillApp/Podfile @@ -9,7 +9,7 @@ target "iosHyperskillApp" do pod "shared", :path => "../shared" - pod "SwiftLint", "0.53.0" + pod "SwiftLint", "0.54.0" pod "Sentry", "8.17.2" # Firebase diff --git a/iosHyperskillApp/Podfile.lock b/iosHyperskillApp/Podfile.lock index 34ec506632..59a5701ff4 100644 --- a/iosHyperskillApp/Podfile.lock +++ b/iosHyperskillApp/Podfile.lock @@ -96,7 +96,7 @@ PODS: - SVGKit (3.1.1): - CocoaLumberjack (~> 3.0) - SVProgressHUD (2.2.5) - - SwiftLint (0.53.0) + - SwiftLint (0.54.0) DEPENDENCIES: - AppsFlyerFramework (= 6.12.2) @@ -118,7 +118,7 @@ DEPENDENCIES: - STRegex (= 2.1.1) - SVGKit (from `https://github.com/SVGKit/SVGKit.git`, branch `3.x`) - SVProgressHUD (= 2.2.5) - - SwiftLint (= 0.53.0) + - SwiftLint (= 0.54.0) SPEC REPOS: trunk: @@ -221,8 +221,8 @@ SPEC CHECKSUMS: STRegex: d49e88d0fe58538d3175fdd989bc1243b9be2a07 SVGKit: 9bc0f0982df559e65b96f2150ff9c15a61e453ac SVProgressHUD: 1428aafac632c1f86f62aa4243ec12008d7a51d6 - SwiftLint: 5ce4d6a8ff83f1b5fd5ad5dbf30965d35af65e44 + SwiftLint: c1de071d9d08c8aba837545f6254315bc900e211 -PODFILE CHECKSUM: 1613f08afc0d23eb5c1805973d23b2f5c885203e +PODFILE CHECKSUM: 1ed5181374da1a28796a674e5e12e4fbb0aad2fa -COCOAPODS: 1.14.3 +COCOAPODS: 1.15.2 diff --git a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj index 80961eddf4..82882ed87f 100644 --- a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj +++ b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj @@ -12,13 +12,18 @@ 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; }; 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 0809817CFCC9D4C45457B3C8 /* ProgressScreenAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AACF19B25D42FD4AE322D5A /* ProgressScreenAssembly.swift */; }; + 09A3FA31C3ABC65467D36662 /* UsersQuestionnaireOnboardingAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80660C909D729D12FEAB845 /* UsersQuestionnaireOnboardingAssembly.swift */; }; 0C3BB55AA2B8FB7F5ED9CADB /* InterviewPreparationOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7D7125CEB88C2B8E29ABBB /* InterviewPreparationOnboardingView.swift */; }; + 0F98394636E12DEC98B7953A /* RequestReviewModalAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF61AAE06DC019B8C49543C /* RequestReviewModalAssembly.swift */; }; + 1CAC118437C9C9910D39009E /* UsersQuestionnaireOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5774D657D505A3A80A7B60D /* UsersQuestionnaireOnboardingView.swift */; }; 2C005DCC27EF5B0300DC6503 /* GoogleServiceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C005DCB27EF5B0300DC6503 /* GoogleServiceInfo.swift */; }; 2C0146AA28FDF2350083DA9C /* StepQuizCodeFullScreenInputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0146A928FDF2350083DA9C /* StepQuizCodeFullScreenInputProtocol.swift */; }; 2C023C86285D927A00D2D5A9 /* StepQuizTableAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C023C85285D927A00D2D5A9 /* StepQuizTableAssembly.swift */; }; 2C023C88285D928100D2D5A9 /* StepQuizTableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C023C87285D928100D2D5A9 /* StepQuizTableViewModel.swift */; }; 2C023C8B285DCA2100D2D5A9 /* ReplyExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C023C8A285DCA2100D2D5A9 /* ReplyExtensions.swift */; }; 2C023C8D285DCA4300D2D5A9 /* DatasetExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C023C8C285DCA4300D2D5A9 /* DatasetExtensions.swift */; }; + 2C0409812B85FC3000E9CF41 /* UsersQuestionnaireOnboardingOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0409802B85FC3000E9CF41 /* UsersQuestionnaireOnboardingOutputProtocol.swift */; }; + 2C0409842B863EA600E9CF41 /* Publishers+KeyboardIsVisible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0409832B863EA600E9CF41 /* Publishers+KeyboardIsVisible.swift */; }; 2C05AC462A0E9EBC0039C7EF /* ProjectSelectionListFeatureViewStateKsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C05AC452A0E9EBC0039C7EF /* ProjectSelectionListFeatureViewStateKsExtensions.swift */; }; 2C05AC482A0EA0180039C7EF /* ProjectSelectionListAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C05AC472A0EA0180039C7EF /* ProjectSelectionListAssembly.swift */; }; 2C05AC4A2A0EA0290039C7EF /* ProjectSelectionListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C05AC492A0EA0290039C7EF /* ProjectSelectionListViewModel.swift */; }; @@ -59,7 +64,7 @@ 2C0F3CFA2A80A42D00947C35 /* BadgeDetailsModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0F3CF92A80A42D00947C35 /* BadgeDetailsModalViewController.swift */; }; 2C0F3CFC2A80A47600947C35 /* BadgeDetailsModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0F3CFB2A80A47600947C35 /* BadgeDetailsModalView.swift */; }; 2C0F3CFF2A80AAA100947C35 /* BadgeLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0F3CFE2A80AAA100947C35 /* BadgeLevelView.swift */; }; - 2C0FA879292FD73400A37636 /* ProfileSettingsFeatureStateKsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0FA878292FD73400A37636 /* ProfileSettingsFeatureStateKsExtensions.swift */; }; + 2C0FA879292FD73400A37636 /* ProfileSettingsFeatureViewStateKsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0FA878292FD73400A37636 /* ProfileSettingsFeatureViewStateKsExtensions.swift */; }; 2C1061A2285C349400EBD614 /* StepQuizChildQuizAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1061A1285C349400EBD614 /* StepQuizChildQuizAssembly.swift */; }; 2C1061A4285C34C900EBD614 /* StepQuizChildQuizOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1061A3285C34C900EBD614 /* StepQuizChildQuizOutputProtocol.swift */; }; 2C1061A8285C3A2D00EBD614 /* StepQuizChildQuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1061A7285C3A2D00EBD614 /* StepQuizChildQuizType.swift */; }; @@ -81,12 +86,16 @@ 2C186ADB2B46989700DADB26 /* TopicsRepetitionsCountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C186ADA2B46989700DADB26 /* TopicsRepetitionsCountView.swift */; }; 2C186ADD2B46A08E00DADB26 /* InterviewPreparationCompletedModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C186ADC2B46A08E00DADB26 /* InterviewPreparationCompletedModalViewController.swift */; }; 2C186ADF2B46A0A300DADB26 /* InterviewPreparationCompletedModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C186ADE2B46A0A300DADB26 /* InterviewPreparationCompletedModalView.swift */; }; + 2C195D042B8467EA0076B2C8 /* UsersQuestionnaireOnboardingContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C195D032B8467EA0076B2C8 /* UsersQuestionnaireOnboardingContentView.swift */; }; + 2C195D062B8482840076B2C8 /* UsersQuestionnaireOnboardingFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C195D052B8482840076B2C8 /* UsersQuestionnaireOnboardingFooterView.swift */; }; + 2C195D092B84863F0076B2C8 /* UsersQuestionnaireOnboardingChoicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C195D082B84863F0076B2C8 /* UsersQuestionnaireOnboardingChoicesView.swift */; }; 2C198DFE2AEA444100DCD35A /* FillBlanksSelectContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C198DFD2AEA444100DCD35A /* FillBlanksSelectContainerView.swift */; }; 2C198E012AEA835F00DCD35A /* StepQuizFillBlanksSelectOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C198E002AEA835F00DCD35A /* StepQuizFillBlanksSelectOptionsView.swift */; }; 2C198E032AEA869300DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C198E022AEA869300DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCell.swift */; }; 2C198E052AEA86DF00DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C198E042AEA86DF00DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift */; }; 2C198E082AEA8BB900DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C198E072AEA8BB900DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift */; }; 2C198E0A2AEA904800DCD35A /* StepQuizFillBlanksSelectOptionsViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C198E092AEA904800DCD35A /* StepQuizFillBlanksSelectOptionsViewWrapper.swift */; }; + 2C1B71052B6CB7D9003FD4A1 /* OffsetObservingScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1B71042B6CB7D9003FD4A1 /* OffsetObservingScrollView.swift */; }; 2C1F5869280D063800372A37 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1F5868280D063800372A37 /* WebViewController.swift */; }; 2C1F586B280D094A00372A37 /* WebControllerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1F586A280D094A00372A37 /* WebControllerManager.swift */; }; 2C1F5870280D0CB700372A37 /* WebCacheCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C1F586F280D0CB700372A37 /* WebCacheCleaner.swift */; }; @@ -131,6 +140,9 @@ 2C27C77C28772F8A006A641A /* ImageDecoders+SVG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C27C77B28772F8A006A641A /* ImageDecoders+SVG.swift */; }; 2C27C77E28773042006A641A /* NukeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C27C77D28773042006A641A /* NukeManager.swift */; }; 2C2B7DD22946EF2800FAB55D /* WebViewNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2B7DD12946EF2800FAB55D /* WebViewNavigationController.swift */; }; + 2C2CCB442B74D0E800D1E596 /* RequestReviewModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2CCB432B74D0E800D1E596 /* RequestReviewModalViewController.swift */; }; + 2C2CCB472B74E71600D1E596 /* RequestReviewModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2CCB462B74E71600D1E596 /* RequestReviewModalView.swift */; }; + 2C2CCB492B74FA6600D1E596 /* UIFont+PreferredFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2CCB482B74FA6600D1E596 /* UIFont+PreferredFont.swift */; }; 2C2D492E281151E100753F16 /* AuthCredentialsAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2D492D281151E100753F16 /* AuthCredentialsAssembly.swift */; }; 2C2D4930281151EB00753F16 /* AuthCredentialsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2D492F281151EB00753F16 /* AuthCredentialsViewModel.swift */; }; 2C2D4932281154CB00753F16 /* AppGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2D4931281154CB00753F16 /* AppGraph.swift */; }; @@ -244,6 +256,8 @@ 2C725B5E28090D1F00A49043 /* View+Border.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C725B5D28090D1F00A49043 /* View+Border.swift */; }; 2C725B612809125700A49043 /* LayoutInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C725B602809125700A49043 /* LayoutInsets.swift */; }; 2C725B632809198000A49043 /* BackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C725B622809198000A49043 /* BackgroundView.swift */; }; + 2C7271242B6B634F005628B0 /* View+Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7271232B6B634F005628B0 /* View+Task.swift */; }; + 2C7271282B6B92AD005628B0 /* PaywallFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7271272B6B92AD005628B0 /* PaywallFooterView.swift */; }; 2C772E7D28ABB4E500A58758 /* AppleIDSocialAuthSDKProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C772E7C28ABB4E500A58758 /* AppleIDSocialAuthSDKProvider.swift */; }; 2C7802C5285C93F900082547 /* StepQuizActionButtonState+SubmissionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7802C4285C93F900082547 /* StepQuizActionButtonState+SubmissionStatus.swift */; }; 2C7822612942F9CF0067200F /* StreakBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7822602942F9CF0067200F /* StreakBarButtonItem.swift */; }; @@ -254,6 +268,7 @@ 2C7994AF2A1299B800874C16 /* TrackSelectionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7994AE2A1299B800874C16 /* TrackSelectionListView.swift */; }; 2C7994B12A129D6100874C16 /* TrackSelectionListSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7994B02A129D6100874C16 /* TrackSelectionListSkeletonView.swift */; }; 2C7A1B1F2922EB070018D72C /* Hyperskill-Mobile_shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7A1B1E2922EB070018D72C /* Hyperskill-Mobile_shared.swift */; settings = {COMPILER_FLAGS = "-w -Xanalyzer -analyzer-disable-all-checks"; }; }; + 2C7C0D632B6B45A20093609D /* PaywallFeaturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7C0D622B6B45A20093609D /* PaywallFeaturesView.swift */; }; 2C7CB66B2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB66A2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift */; }; 2C7CB66D2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB66C2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift */; }; 2C7CB66F2ADFB96F006F78DA /* StepQuizFillBlanksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB66E2ADFB96F006F78DA /* StepQuizFillBlanksView.swift */; }; @@ -269,6 +284,7 @@ 2C80D4FD288C4D0D00B2CD1E /* StepQuizCodeFullScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C80D4FC288C4D0D00B2CD1E /* StepQuizCodeFullScreenViewModel.swift */; }; 2C80D4FF288C4D4400B2CD1E /* StepQuizCodeFullScreenOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C80D4FE288C4D4400B2CD1E /* StepQuizCodeFullScreenOutputProtocol.swift */; }; 2C80D503288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C80D502288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift */; }; + 2C829B912B88583300765335 /* StepQuizUnsupportedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C829B902B88583300765335 /* StepQuizUnsupportedView.swift */; }; 2C82BA322844B01D004C9013 /* PlaceholderView+Configurations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C82BA312844B01D004C9013 /* PlaceholderView+Configurations.swift */; }; 2C83FBBE2B177633007AD7E2 /* LeaderboardTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C83FBBD2B177633007AD7E2 /* LeaderboardTab.swift */; }; 2C83FBC02B177F68007AD7E2 /* LeaderboardListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C83FBBF2B177F68007AD7E2 /* LeaderboardListView.swift */; }; @@ -300,6 +316,7 @@ 2C919E3327EEF92F0022A2F2 /* LinkedListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C919E3227EEF92F0022A2F2 /* LinkedListTests.swift */; }; 2C919E3527EEFF110022A2F2 /* Queue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C919E3427EEFF110022A2F2 /* Queue.swift */; }; 2C919E3727EF00950022A2F2 /* QueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C919E3627EF00950022A2F2 /* QueueTests.swift */; }; + 2C9320F52B68F14100999992 /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9320F42B68F14100999992 /* PaywallView.swift */; }; 2C93AF1F29B34A88004639E0 /* StepQuizPyCharmViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C93AF1E29B34A88004639E0 /* StepQuizPyCharmViewModel.swift */; }; 2C93AF2129B34C5A004639E0 /* StepQuizPyCharmViewDataMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C93AF2029B34C5A004639E0 /* StepQuizPyCharmViewDataMapper.swift */; }; 2C93AF2329B34F66004639E0 /* StepQuizPyCharmView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C93AF2229B34F66004639E0 /* StepQuizPyCharmView.swift */; }; @@ -334,6 +351,7 @@ 2C98C7AD2850B93100857783 /* icons.css in Resources */ = {isa = PBXBuildFile; fileRef = 2C98C7A52850B93100857783 /* icons.css */; }; 2C99B0FD2A141BF10018627B /* StudyPlanSectionItemBadgesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C99B0FC2A141BF10018627B /* StudyPlanSectionItemBadgesView.swift */; }; 2C99B1002A14255F0018627B /* StudyPlanWidgetViewStateSectionItemStateWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C99B0FF2A14255F0018627B /* StudyPlanWidgetViewStateSectionItemStateWrapper.swift */; }; + 2C9ACAA62B870E3D00FE63FA /* AppRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9ACAA52B870E3D00FE63FA /* AppRouter.swift */; }; 2C9B66C427ECA73700569645 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9B66C327ECA73700569645 /* AppDelegate.swift */; }; 2C9CC1BD280920B5006604D7 /* KeyboardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9CC1BC280920B5006604D7 /* KeyboardManager.swift */; }; 2C9CC1BF28092E06006604D7 /* AuthAdaptiveContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C9CC1BE28092E06006604D7 /* AuthAdaptiveContentView.swift */; }; @@ -372,6 +390,10 @@ 2CAA3C6A2AA9C7B6004F6CE6 /* LottieAnimations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CAA3C692AA9C7B6004F6CE6 /* LottieAnimations.swift */; }; 2CAA3C6D2AA9CA9D004F6CE6 /* StepQuizProblemOnboardingModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CAA3C6C2AA9CA9D004F6CE6 /* StepQuizProblemOnboardingModalViewController.swift */; }; 2CAA3C6F2AA9CAB1004F6CE6 /* StepQuizProblemOnboardingModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CAA3C6E2AA9CAB1004F6CE6 /* StepQuizProblemOnboardingModalView.swift */; }; + 2CACBCBC2B7A12F1006D3AB2 /* UsersQuestionnaireWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CACBCBB2B7A12F1006D3AB2 /* UsersQuestionnaireWidgetView.swift */; }; + 2CACBCBE2B7A1365006D3AB2 /* UsersQuestionnaireWidgetViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CACBCBD2B7A1365006D3AB2 /* UsersQuestionnaireWidgetViewModel.swift */; }; + 2CACBCC02B7A137A006D3AB2 /* UsersQuestionnaireWidgetOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CACBCBF2B7A137A006D3AB2 /* UsersQuestionnaireWidgetOutputProtocol.swift */; }; + 2CACBCC22B7A3E4E006D3AB2 /* UsersQuestionnaireWidgetAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CACBCC12B7A3E4E006D3AB2 /* UsersQuestionnaireWidgetAssembly.swift */; }; 2CAE8CF0280525BE00E6C83D /* StepViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CAE8CEF280525BE00E6C83D /* StepViewModel.swift */; }; 2CAE8CF2280525C900E6C83D /* StepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CAE8CF1280525C900E6C83D /* StepView.swift */; }; 2CAE8CF4280525D400E6C83D /* StepAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CAE8CF3280525D400E6C83D /* StepAssembly.swift */; }; @@ -413,6 +435,7 @@ 2CBFB94A28897DBB0044D1BA /* StepQuizCodeFullScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CBFB94928897DBB0044D1BA /* StepQuizCodeFullScreenView.swift */; }; 2CBFB94C28897DD70044D1BA /* StepQuizCodeFullScreenAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CBFB94B28897DD70044D1BA /* StepQuizCodeFullScreenAssembly.swift */; }; 2CC4AAF1280DB513002276A0 /* WebOAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC4AAF0280DB513002276A0 /* WebOAuthService.swift */; }; + 2CC63AEC2B70B25200407810 /* ProfileSettingsSubscriptionSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC63AEB2B70B25200407810 /* ProfileSettingsSubscriptionSectionView.swift */; }; 2CC7833E295DAE3E00A867CD /* WelcomeFeatureStateKsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC7833D295DAE3E00A867CD /* WelcomeFeatureStateKsExtensions.swift */; }; 2CC78D0928C74E7D0006EF91 /* UIViewControllerEventsWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC78D0828C74E7D0006EF91 /* UIViewControllerEventsWrapper.swift */; }; 2CC78D0C28C74EF90006EF91 /* ViewRelatedEventsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC78D0B28C74EF90006EF91 /* ViewRelatedEventsViewController.swift */; }; @@ -426,6 +449,8 @@ 2CCCA3A12862E62F00D98089 /* StepQuizStringViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CCCA3A02862E62F00D98089 /* StepQuizStringViewData.swift */; }; 2CCF3B5828004FC40075D12C /* UserAgentBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CCF3B5728004FC40075D12C /* UserAgentBuilder.swift */; }; 2CCF3B5A280050890075D12C /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CCF3B59280050890075D12C /* DeviceInfo.swift */; }; + 2CD20ED12B73475400FB5269 /* ApplicationShortcutsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD20ED02B73475400FB5269 /* ApplicationShortcutsService.swift */; }; + 2CD20ED42B73484200FB5269 /* ApplicationShortcutIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD20ED32B73484200FB5269 /* ApplicationShortcutIdentifier.swift */; }; 2CD316C028A3B2040002B2B2 /* ApplicationTheme+SharedTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD316BF28A3B2040002B2B2 /* ApplicationTheme+SharedTheme.swift */; }; 2CD3652528796C4300D61855 /* ProfileViewDataMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD3652428796C4300D61855 /* ProfileViewDataMapper.swift */; }; 2CD3652828797D3600D61855 /* Formatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD3652728797D3600D61855 /* Formatter.swift */; }; @@ -437,6 +462,8 @@ 2CD48D892858657100CFCC4A /* StepQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD48D882858657100CFCC4A /* StepQuizView.swift */; }; 2CD48D8B2858684100CFCC4A /* StepQuizViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD48D8A2858684100CFCC4A /* StepQuizViewData.swift */; }; 2CD48D8E28586B6F00CFCC4A /* StepQuizViewDataMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD48D8D28586B6F00CFCC4A /* StepQuizViewDataMapper.swift */; }; + 2CD4EDF92B79D51E0091F0B2 /* View+SafeAreaInset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD4EDF82B79D51E0091F0B2 /* View+SafeAreaInset.swift */; }; + 2CD4EDFB2B79D74B0091F0B2 /* TransparentBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CD4EDFA2B79D74B0091F0B2 /* TransparentBlurView.swift */; }; 2CDA9838294432C900ADE539 /* SkeletonCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDA9837294432C900ADE539 /* SkeletonCircleView.swift */; }; 2CDA98412944512D00ADE539 /* ProfileSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDA98402944512D00ADE539 /* ProfileSkeletonView.swift */; }; 2CDA98432944524D00ADE539 /* HomeSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CDA98422944524D00ADE539 /* HomeSkeletonView.swift */; }; @@ -455,6 +482,7 @@ 2CE31F4827F1BB79008EEE66 /* AuthSocialAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE31F4727F1BB79008EEE66 /* AuthSocialAssembly.swift */; }; 2CE31F4B27F1E070008EEE66 /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE31F4A27F1E070008EEE66 /* AppViewModel.swift */; }; 2CE31F4D27F1E0C8008EEE66 /* AppAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE31F4C27F1E0C8008EEE66 /* AppAssembly.swift */; }; + 2CE4F0732B71D358001FD376 /* SubscriptionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE4F0722B71D358001FD376 /* SubscriptionDetailsView.swift */; }; 2CE58C5A2B07662300E5EBBE /* ChallengeWidgetContentStateProgressGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE58C592B07662300E5EBBE /* ChallengeWidgetContentStateProgressGridView.swift */; }; 2CE58C5C2B0768F300E5EBBE /* ChallengeWidgetContentStateProgressGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE58C5B2B0768F300E5EBBE /* ChallengeWidgetContentStateProgressGridItemView.swift */; }; 2CE601362B3345DD00E9CC46 /* ColorResource+UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE601352B3345DD00E9CC46 /* ColorResource+UIColor.swift */; }; @@ -506,11 +534,13 @@ 2CFD7C6A2925447600902748 /* StepQuizFeatureStateKsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CFD7C692925447600902748 /* StepQuizFeatureStateKsExtensions.swift */; }; 40D8E6EFE44EB7A6092C171B /* Pods_iosHyperskillApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9C0F8A86D62CB915A1E49CAA /* Pods_iosHyperskillApp.framework */; }; 4F5F2FD2F3BCAC06612FCAE8 /* InterviewPreparationOnboardingAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = B431D493DED8999E3F3B6968 /* InterviewPreparationOnboardingAssembly.swift */; }; + 4FBE20D41C99246C44E068AA /* UsersQuestionnaireOnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 772BEE130815F1450D253FE3 /* UsersQuestionnaireOnboardingViewModel.swift */; }; 59B66CD4D1508049555D35AE /* ProgressScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC18157582494D2909B214C /* ProgressScreenView.swift */; }; 60B4F143CF507F83C9581020 /* LeaderboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E205DEF27554501F7BE01AA /* LeaderboardViewModel.swift */; }; 63FC2C36279DBA43CCEA1360 /* InterviewPreparationOnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9224BDAA50119E9135E1B74 /* InterviewPreparationOnboardingViewModel.swift */; }; 7A628C36D862C98ED2046D4F /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907B10B0F7D4970530A478A2 /* SearchView.swift */; }; 8E154CD6AF7D45A2CA013F85 /* SearchAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F55BD539626D22DCF0E1344 /* SearchAssembly.swift */; }; + 91046416561EE431760D7D48 /* RequestReviewModalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D1A5B08EE626D2D612CEAE /* RequestReviewModalViewModel.swift */; }; 9195A8624F8058A7D5F936F8 /* NotificationsOnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3570563AEEEEF2F5495BCA6 /* NotificationsOnboardingViewModel.swift */; }; AE0B2D1D267B8904498FA371 /* ProjectSelectionDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDCC11294912B8656C8B264 /* ProjectSelectionDetailsViewModel.swift */; }; B2B30D0486FC13DCC80F4263 /* NotificationsOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3944E4546DEF47A28B2E7292 /* NotificationsOnboardingView.swift */; }; @@ -694,6 +724,8 @@ 2C023C87285D928100D2D5A9 /* StepQuizTableViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizTableViewModel.swift; sourceTree = ""; }; 2C023C8A285DCA2100D2D5A9 /* ReplyExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyExtensions.swift; sourceTree = ""; }; 2C023C8C285DCA4300D2D5A9 /* DatasetExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatasetExtensions.swift; sourceTree = ""; }; + 2C0409802B85FC3000E9CF41 /* UsersQuestionnaireOnboardingOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersQuestionnaireOnboardingOutputProtocol.swift; sourceTree = ""; }; + 2C0409832B863EA600E9CF41 /* Publishers+KeyboardIsVisible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publishers+KeyboardIsVisible.swift"; sourceTree = ""; }; 2C05AC452A0E9EBC0039C7EF /* ProjectSelectionListFeatureViewStateKsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSelectionListFeatureViewStateKsExtensions.swift; sourceTree = ""; }; 2C05AC472A0EA0180039C7EF /* ProjectSelectionListAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSelectionListAssembly.swift; sourceTree = ""; }; 2C05AC492A0EA0290039C7EF /* ProjectSelectionListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSelectionListViewModel.swift; sourceTree = ""; }; @@ -734,7 +766,7 @@ 2C0F3CF92A80A42D00947C35 /* BadgeDetailsModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeDetailsModalViewController.swift; sourceTree = ""; }; 2C0F3CFB2A80A47600947C35 /* BadgeDetailsModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeDetailsModalView.swift; sourceTree = ""; }; 2C0F3CFE2A80AAA100947C35 /* BadgeLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeLevelView.swift; sourceTree = ""; }; - 2C0FA878292FD73400A37636 /* ProfileSettingsFeatureStateKsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSettingsFeatureStateKsExtensions.swift; sourceTree = ""; }; + 2C0FA878292FD73400A37636 /* ProfileSettingsFeatureViewStateKsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSettingsFeatureViewStateKsExtensions.swift; sourceTree = ""; }; 2C1061A1285C349400EBD614 /* StepQuizChildQuizAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizChildQuizAssembly.swift; sourceTree = ""; }; 2C1061A3285C34C900EBD614 /* StepQuizChildQuizOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizChildQuizOutputProtocol.swift; sourceTree = ""; }; 2C1061A7285C3A2D00EBD614 /* StepQuizChildQuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizChildQuizType.swift; sourceTree = ""; }; @@ -757,12 +789,16 @@ 2C186ADA2B46989700DADB26 /* TopicsRepetitionsCountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicsRepetitionsCountView.swift; sourceTree = ""; }; 2C186ADC2B46A08E00DADB26 /* InterviewPreparationCompletedModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterviewPreparationCompletedModalViewController.swift; sourceTree = ""; }; 2C186ADE2B46A0A300DADB26 /* InterviewPreparationCompletedModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterviewPreparationCompletedModalView.swift; sourceTree = ""; }; + 2C195D032B8467EA0076B2C8 /* UsersQuestionnaireOnboardingContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersQuestionnaireOnboardingContentView.swift; sourceTree = ""; }; + 2C195D052B8482840076B2C8 /* UsersQuestionnaireOnboardingFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersQuestionnaireOnboardingFooterView.swift; sourceTree = ""; }; + 2C195D082B84863F0076B2C8 /* UsersQuestionnaireOnboardingChoicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersQuestionnaireOnboardingChoicesView.swift; sourceTree = ""; }; 2C198DFD2AEA444100DCD35A /* FillBlanksSelectContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksSelectContainerView.swift; sourceTree = ""; }; 2C198E002AEA835F00DCD35A /* StepQuizFillBlanksSelectOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsView.swift; sourceTree = ""; }; 2C198E022AEA869300DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsCollectionViewCell.swift; sourceTree = ""; }; 2C198E042AEA86DF00DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView.swift; sourceTree = ""; }; 2C198E072AEA8BB900DCD35A /* StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsCollectionViewAdapter.swift; sourceTree = ""; }; 2C198E092AEA904800DCD35A /* StepQuizFillBlanksSelectOptionsViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksSelectOptionsViewWrapper.swift; sourceTree = ""; }; + 2C1B71042B6CB7D9003FD4A1 /* OffsetObservingScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetObservingScrollView.swift; sourceTree = ""; }; 2C1F5868280D063800372A37 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; 2C1F586A280D094A00372A37 /* WebControllerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebControllerManager.swift; sourceTree = ""; }; 2C1F586F280D0CB700372A37 /* WebCacheCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebCacheCleaner.swift; sourceTree = ""; }; @@ -809,6 +845,9 @@ 2C27C77B28772F8A006A641A /* ImageDecoders+SVG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDecoders+SVG.swift"; sourceTree = ""; }; 2C27C77D28773042006A641A /* NukeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeManager.swift; sourceTree = ""; }; 2C2B7DD12946EF2800FAB55D /* WebViewNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNavigationController.swift; sourceTree = ""; }; + 2C2CCB432B74D0E800D1E596 /* RequestReviewModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestReviewModalViewController.swift; sourceTree = ""; }; + 2C2CCB462B74E71600D1E596 /* RequestReviewModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestReviewModalView.swift; sourceTree = ""; }; + 2C2CCB482B74FA6600D1E596 /* UIFont+PreferredFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+PreferredFont.swift"; sourceTree = ""; }; 2C2CD8A52AB44CD5007B2581 /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = ""; }; 2C2D492D281151E100753F16 /* AuthCredentialsAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthCredentialsAssembly.swift; sourceTree = ""; }; 2C2D492F281151EB00753F16 /* AuthCredentialsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthCredentialsViewModel.swift; sourceTree = ""; }; @@ -926,6 +965,8 @@ 2C725B5D28090D1F00A49043 /* View+Border.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Border.swift"; sourceTree = ""; }; 2C725B602809125700A49043 /* LayoutInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutInsets.swift; sourceTree = ""; }; 2C725B622809198000A49043 /* BackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundView.swift; sourceTree = ""; }; + 2C7271232B6B634F005628B0 /* View+Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Task.swift"; sourceTree = ""; }; + 2C7271272B6B92AD005628B0 /* PaywallFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallFooterView.swift; sourceTree = ""; }; 2C772E7C28ABB4E500A58758 /* AppleIDSocialAuthSDKProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleIDSocialAuthSDKProvider.swift; sourceTree = ""; }; 2C7802C4285C93F900082547 /* StepQuizActionButtonState+SubmissionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StepQuizActionButtonState+SubmissionStatus.swift"; sourceTree = ""; }; 2C7822602942F9CF0067200F /* StreakBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakBarButtonItem.swift; sourceTree = ""; }; @@ -936,6 +977,7 @@ 2C7994AE2A1299B800874C16 /* TrackSelectionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackSelectionListView.swift; sourceTree = ""; }; 2C7994B02A129D6100874C16 /* TrackSelectionListSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackSelectionListSkeletonView.swift; sourceTree = ""; }; 2C7A1B1E2922EB070018D72C /* Hyperskill-Mobile_shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Hyperskill-Mobile_shared.swift"; sourceTree = ""; }; + 2C7C0D622B6B45A20093609D /* PaywallFeaturesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallFeaturesView.swift; sourceTree = ""; }; 2C7CB66A2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksAssembly.swift; sourceTree = ""; }; 2C7CB66C2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksViewModel.swift; sourceTree = ""; }; 2C7CB66E2ADFB96F006F78DA /* StepQuizFillBlanksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksView.swift; sourceTree = ""; }; @@ -951,6 +993,7 @@ 2C80D4FC288C4D0D00B2CD1E /* StepQuizCodeFullScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenViewModel.swift; sourceTree = ""; }; 2C80D4FE288C4D4400B2CD1E /* StepQuizCodeFullScreenOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenOutputProtocol.swift; sourceTree = ""; }; 2C80D502288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeNavigationState.swift; sourceTree = ""; }; + 2C829B902B88583300765335 /* StepQuizUnsupportedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizUnsupportedView.swift; sourceTree = ""; }; 2C82BA312844B01D004C9013 /* PlaceholderView+Configurations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlaceholderView+Configurations.swift"; sourceTree = ""; }; 2C83FBBD2B177633007AD7E2 /* LeaderboardTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaderboardTab.swift; sourceTree = ""; }; 2C83FBBF2B177F68007AD7E2 /* LeaderboardListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaderboardListView.swift; sourceTree = ""; }; @@ -983,6 +1026,7 @@ 2C919E3227EEF92F0022A2F2 /* LinkedListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedListTests.swift; sourceTree = ""; }; 2C919E3427EEFF110022A2F2 /* Queue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Queue.swift; sourceTree = ""; }; 2C919E3627EF00950022A2F2 /* QueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueTests.swift; sourceTree = ""; }; + 2C9320F42B68F14100999992 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = ""; }; 2C93AF1E29B34A88004639E0 /* StepQuizPyCharmViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizPyCharmViewModel.swift; sourceTree = ""; }; 2C93AF2029B34C5A004639E0 /* StepQuizPyCharmViewDataMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizPyCharmViewDataMapper.swift; sourceTree = ""; }; 2C93AF2229B34F66004639E0 /* StepQuizPyCharmView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizPyCharmView.swift; sourceTree = ""; }; @@ -1017,6 +1061,7 @@ 2C98C7A52850B93100857783 /* icons.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = icons.css; sourceTree = ""; }; 2C99B0FC2A141BF10018627B /* StudyPlanSectionItemBadgesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyPlanSectionItemBadgesView.swift; sourceTree = ""; }; 2C99B0FF2A14255F0018627B /* StudyPlanWidgetViewStateSectionItemStateWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyPlanWidgetViewStateSectionItemStateWrapper.swift; sourceTree = ""; }; + 2C9ACAA52B870E3D00FE63FA /* AppRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouter.swift; sourceTree = ""; }; 2C9B66C327ECA73700569645 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 2C9CC1BC280920B5006604D7 /* KeyboardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardManager.swift; sourceTree = ""; }; 2C9CC1BE28092E06006604D7 /* AuthAdaptiveContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAdaptiveContentView.swift; sourceTree = ""; }; @@ -1055,6 +1100,10 @@ 2CAA3C692AA9C7B6004F6CE6 /* LottieAnimations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieAnimations.swift; sourceTree = ""; }; 2CAA3C6C2AA9CA9D004F6CE6 /* StepQuizProblemOnboardingModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizProblemOnboardingModalViewController.swift; sourceTree = ""; }; 2CAA3C6E2AA9CAB1004F6CE6 /* StepQuizProblemOnboardingModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizProblemOnboardingModalView.swift; sourceTree = ""; }; + 2CACBCBB2B7A12F1006D3AB2 /* UsersQuestionnaireWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersQuestionnaireWidgetView.swift; sourceTree = ""; }; + 2CACBCBD2B7A1365006D3AB2 /* UsersQuestionnaireWidgetViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersQuestionnaireWidgetViewModel.swift; sourceTree = ""; }; + 2CACBCBF2B7A137A006D3AB2 /* UsersQuestionnaireWidgetOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersQuestionnaireWidgetOutputProtocol.swift; sourceTree = ""; }; + 2CACBCC12B7A3E4E006D3AB2 /* UsersQuestionnaireWidgetAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersQuestionnaireWidgetAssembly.swift; sourceTree = ""; }; 2CAE8CEF280525BE00E6C83D /* StepViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepViewModel.swift; sourceTree = ""; }; 2CAE8CF1280525C900E6C83D /* StepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepView.swift; sourceTree = ""; }; 2CAE8CF3280525D400E6C83D /* StepAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepAssembly.swift; sourceTree = ""; }; @@ -1096,6 +1145,7 @@ 2CBFB94928897DBB0044D1BA /* StepQuizCodeFullScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenView.swift; sourceTree = ""; }; 2CBFB94B28897DD70044D1BA /* StepQuizCodeFullScreenAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenAssembly.swift; sourceTree = ""; }; 2CC4AAF0280DB513002276A0 /* WebOAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebOAuthService.swift; sourceTree = ""; }; + 2CC63AEB2B70B25200407810 /* ProfileSettingsSubscriptionSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSettingsSubscriptionSectionView.swift; sourceTree = ""; }; 2CC7833D295DAE3E00A867CD /* WelcomeFeatureStateKsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeFeatureStateKsExtensions.swift; sourceTree = ""; }; 2CC78D0828C74E7D0006EF91 /* UIViewControllerEventsWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewControllerEventsWrapper.swift; sourceTree = ""; }; 2CC78D0B28C74EF90006EF91 /* ViewRelatedEventsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewRelatedEventsViewController.swift; sourceTree = ""; }; @@ -1109,6 +1159,8 @@ 2CCCA3A02862E62F00D98089 /* StepQuizStringViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizStringViewData.swift; sourceTree = ""; }; 2CCF3B5728004FC40075D12C /* UserAgentBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentBuilder.swift; sourceTree = ""; }; 2CCF3B59280050890075D12C /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; + 2CD20ED02B73475400FB5269 /* ApplicationShortcutsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationShortcutsService.swift; sourceTree = ""; }; + 2CD20ED32B73484200FB5269 /* ApplicationShortcutIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationShortcutIdentifier.swift; sourceTree = ""; }; 2CD316BF28A3B2040002B2B2 /* ApplicationTheme+SharedTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ApplicationTheme+SharedTheme.swift"; sourceTree = ""; }; 2CD3652428796C4300D61855 /* ProfileViewDataMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewDataMapper.swift; sourceTree = ""; }; 2CD3652728797D3600D61855 /* Formatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formatter.swift; sourceTree = ""; }; @@ -1120,6 +1172,8 @@ 2CD48D882858657100CFCC4A /* StepQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizView.swift; sourceTree = ""; }; 2CD48D8A2858684100CFCC4A /* StepQuizViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizViewData.swift; sourceTree = ""; }; 2CD48D8D28586B6F00CFCC4A /* StepQuizViewDataMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizViewDataMapper.swift; sourceTree = ""; }; + 2CD4EDF82B79D51E0091F0B2 /* View+SafeAreaInset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+SafeAreaInset.swift"; sourceTree = ""; }; + 2CD4EDFA2B79D74B0091F0B2 /* TransparentBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentBlurView.swift; sourceTree = ""; }; 2CDA9837294432C900ADE539 /* SkeletonCircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCircleView.swift; sourceTree = ""; }; 2CDA98402944512D00ADE539 /* ProfileSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSkeletonView.swift; sourceTree = ""; }; 2CDA98422944524D00ADE539 /* HomeSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSkeletonView.swift; sourceTree = ""; }; @@ -1138,6 +1192,7 @@ 2CE31F4727F1BB79008EEE66 /* AuthSocialAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSocialAssembly.swift; sourceTree = ""; }; 2CE31F4A27F1E070008EEE66 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = ""; }; 2CE31F4C27F1E0C8008EEE66 /* AppAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAssembly.swift; sourceTree = ""; }; + 2CE4F0722B71D358001FD376 /* SubscriptionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionDetailsView.swift; sourceTree = ""; }; 2CE58C592B07662300E5EBBE /* ChallengeWidgetContentStateProgressGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetContentStateProgressGridView.swift; sourceTree = ""; }; 2CE58C5B2B0768F300E5EBBE /* ChallengeWidgetContentStateProgressGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeWidgetContentStateProgressGridItemView.swift; sourceTree = ""; }; 2CE601352B3345DD00E9CC46 /* ColorResource+UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ColorResource+UIColor.swift"; sourceTree = ""; }; @@ -1191,22 +1246,27 @@ 2D7F5C51275BBB18DCE9ACE9 /* Pods-iosHyperskillApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosHyperskillApp.debug.xcconfig"; path = "Target Support Files/Pods-iosHyperskillApp/Pods-iosHyperskillApp.debug.xcconfig"; sourceTree = ""; }; 2E205DEF27554501F7BE01AA /* LeaderboardViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LeaderboardViewModel.swift; sourceTree = ""; }; 3944E4546DEF47A28B2E7292 /* NotificationsOnboardingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsOnboardingView.swift; sourceTree = ""; }; + 46D1A5B08EE626D2D612CEAE /* RequestReviewModalViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RequestReviewModalViewModel.swift; sourceTree = ""; }; + 4FF61AAE06DC019B8C49543C /* RequestReviewModalAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RequestReviewModalAssembly.swift; sourceTree = ""; }; 515FEBC2A5D8EFBA7FB80795 /* Pods-iosHyperskillApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosHyperskillApp.release.xcconfig"; path = "Target Support Files/Pods-iosHyperskillApp/Pods-iosHyperskillApp.release.xcconfig"; sourceTree = ""; }; 522FFA89F67884772E338BD7 /* LeaderboardView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LeaderboardView.swift; sourceTree = ""; }; 5FB20AE82459AAF98DA40D48 /* TrackSelectionDetailsViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrackSelectionDetailsViewModel.swift; sourceTree = ""; }; 71D01125D308034C53D75DA6 /* ProjectSelectionDetailsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProjectSelectionDetailsView.swift; sourceTree = ""; }; 7555FF7B242A565900829871 /* iosHyperskillApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosHyperskillApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 772BEE130815F1450D253FE3 /* UsersQuestionnaireOnboardingViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UsersQuestionnaireOnboardingViewModel.swift; sourceTree = ""; }; 7A7D7125CEB88C2B8E29ABBB /* InterviewPreparationOnboardingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InterviewPreparationOnboardingView.swift; sourceTree = ""; }; 7F55BD539626D22DCF0E1344 /* SearchAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchAssembly.swift; sourceTree = ""; }; 907B10B0F7D4970530A478A2 /* SearchView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 9AACF19B25D42FD4AE322D5A /* ProgressScreenAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProgressScreenAssembly.swift; sourceTree = ""; }; 9C0F8A86D62CB915A1E49CAA /* Pods_iosHyperskillApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosHyperskillApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A5774D657D505A3A80A7B60D /* UsersQuestionnaireOnboardingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UsersQuestionnaireOnboardingView.swift; sourceTree = ""; }; B431D493DED8999E3F3B6968 /* InterviewPreparationOnboardingAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InterviewPreparationOnboardingAssembly.swift; sourceTree = ""; }; C2065D585FD89A96C31C08BC /* TrackSelectionDetailsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrackSelectionDetailsView.swift; sourceTree = ""; }; C2D19C8A442CF9C4F00370B8 /* NotificationsOnboardingAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsOnboardingAssembly.swift; sourceTree = ""; }; CCC18157582494D2909B214C /* ProgressScreenView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProgressScreenView.swift; sourceTree = ""; }; CCFDFC2C226D2B79DE30D811 /* LeaderboardAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LeaderboardAssembly.swift; sourceTree = ""; }; + D80660C909D729D12FEAB845 /* UsersQuestionnaireOnboardingAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UsersQuestionnaireOnboardingAssembly.swift; sourceTree = ""; }; D9224BDAA50119E9135E1B74 /* InterviewPreparationOnboardingViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InterviewPreparationOnboardingViewModel.swift; sourceTree = ""; }; E3570563AEEEEF2F5495BCA6 /* NotificationsOnboardingViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsOnboardingViewModel.swift; sourceTree = ""; }; E900D10028434D0400A77BBC /* StepQuizSortingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizSortingView.swift; sourceTree = ""; }; @@ -1422,6 +1482,14 @@ path = Shared; sourceTree = ""; }; + 2C0409822B863E8A00E9CF41 /* Combine */ = { + isa = PBXGroup; + children = ( + 2C0409832B863EA600E9CF41 /* Publishers+KeyboardIsVisible.swift */, + ); + path = Combine; + sourceTree = ""; + }; 2C05AC442A0E9E910039C7EF /* List */ = { isa = PBXGroup; children = ( @@ -1590,7 +1658,7 @@ 2C186AD22B468DBE00DADB26 /* InterviewPreparationWidgetViewStateKsExtensions.swift */, E9B55A5429C8A03E0066900E /* ProblemsLimitFeatureViewStateKsExtensions.swift */, 2C0DFB992923BB4300D30921 /* ProfileFeatureStateKsExtensions.swift */, - 2C0FA878292FD73400A37636 /* ProfileSettingsFeatureStateKsExtensions.swift */, + 2C0FA878292FD73400A37636 /* ProfileSettingsFeatureViewStateKsExtensions.swift */, 2C5CA2362A20185300DBF2F9 /* ProjectSelectionDetailsFeatureViewStateKsExtensions.swift */, 2C05AC452A0E9EBC0039C7EF /* ProjectSelectionListFeatureViewStateKsExtensions.swift */, 2C306A0D29B4590C0068FF4F /* StageImplementFeatureViewStateKsExtensions.swift */, @@ -1644,6 +1712,17 @@ path = InterviewPreparationCompleted; sourceTree = ""; }; + 2C195D072B8483EA0076B2C8 /* Views */ = { + isa = PBXGroup; + children = ( + 2C195D082B84863F0076B2C8 /* UsersQuestionnaireOnboardingChoicesView.swift */, + 2C195D032B8467EA0076B2C8 /* UsersQuestionnaireOnboardingContentView.swift */, + 2C195D052B8482840076B2C8 /* UsersQuestionnaireOnboardingFooterView.swift */, + A5774D657D505A3A80A7B60D /* UsersQuestionnaireOnboardingView.swift */, + ); + path = Views; + sourceTree = ""; + }; 2C198DFC2AEA441E00DCD35A /* Select */ = { isa = PBXGroup; children = ( @@ -1702,11 +1781,13 @@ 58E45951DA331A5F7A54B0BA /* InterviewPreparationOnboarding */, E6992F3BBF430924F32DC178 /* Leaderboard */, B58361EACE24BF4B761F10BA /* NotificationsOnboarding */, + 2C9320F32B68F13000999992 /* Paywall */, E9B55A5329C89FFF0066900E /* ProblemsLimit */, 2C9EB95B2861BAAE007DDE44 /* Profile */, 2C963BC82812D3410036DD53 /* ProfileSettings */, 69443CBBFA46C4A121EA173F /* ProgressScreen */, 2C5CA2452A203C4500DBF2F9 /* ProjectSelection */, + 9C3B04E6CBDCDDFE888D5DAF /* RequestReview */, 3C00014807122833363E303F /* Search */, 2C9E5E8229B211DD003AEC16 /* StageImplement */, 2CAE8CEE280525A100E6C83D /* Step */, @@ -1714,8 +1795,10 @@ 2CE7B4852AB05AA300DCBE4D /* StepQuizSubmodules */, E9F655CE2875B30B00291143 /* Streak */, E9F0A2A729D416AC00C4A61E /* StudyPlan */, + 2CE4F0712B71D347001FD376 /* SubscriptionDetails */, E9A022AB291D0E1C004317DB /* TopicsRepetitions */, 2C2600862A2001E600BD3D39 /* TrackSelection */, + 2CB559302B87024000D949CB /* UsersQuestionnaire */, E9F923F428A2632800C065A7 /* Welcome */, ); path = Modules; @@ -1772,6 +1855,7 @@ 2C1F5880280D5B8200372A37 /* Services */ = { isa = PBXGroup; children = ( + 2CD20ED22B73481800FB5269 /* ApplicationShortcuts */, 2C336D112865C46400C91342 /* ApplicationTheme */, 2C1F5883280D5BE600372A37 /* Auth */, ); @@ -1928,7 +2012,9 @@ 2CF4341128126C79002893CD /* View+EndEditing.swift */, 2C177EC22837B65500D841DB /* View+Frame.swift */, E9FAF38E299F61AE001FC596 /* View+MeasureSize.swift */, + 2CD4EDF82B79D51E0091F0B2 /* View+SafeAreaInset.swift */, 2C4605B02ABD75FC003C17E9 /* View+ScrollBounceBehavior.swift */, + 2C7271232B6B634F005628B0 /* View+Task.swift */, ); path = View; sourceTree = ""; @@ -2101,6 +2187,7 @@ 2CA7614A2926272500987B66 /* StepQuizFeedbackView.swift */, E99B21802887E535006A6154 /* StepQuizSkeletonViewFactory.swift */, E99D4CEE2826B91100B49D52 /* StepQuizStatusView.swift */, + 2C829B902B88583300765335 /* StepQuizUnsupportedView.swift */, 2CD48D882858657100CFCC4A /* StepQuizView.swift */, 2C41127628376F50004948A3 /* BottomControls */, 2CCD49712838E40F004DC3CE /* Header */, @@ -2182,6 +2269,7 @@ 2CE7B4832AB0593F00DCBE4D /* AttributedTextLabelWrapper.swift */, 2CAA3C622AA9C196004F6CE6 /* LottieAnimationViewWrapper.swift */, 2C46D0622807E4C100B3636E /* TextFieldWrapper.swift */, + 2CD4EDFA2B79D74B0091F0B2 /* TransparentBlurView.swift */, 2CC78D0A28C74EAF0006EF91 /* UIViewControllerEvents */, ); path = Wrappers; @@ -2684,6 +2772,16 @@ path = iosHyperskillAppTests; sourceTree = ""; }; + 2C9320F32B68F13000999992 /* Paywall */ = { + isa = PBXGroup; + children = ( + 2C7C0D622B6B45A20093609D /* PaywallFeaturesView.swift */, + 2C7271272B6B92AD005628B0 /* PaywallFooterView.swift */, + 2C9320F42B68F14100999992 /* PaywallView.swift */, + ); + path = Paywall; + sourceTree = ""; + }; 2C93AF1D29B349AC004639E0 /* StepQuizPyCharm */ = { isa = PBXGroup; children = ( @@ -2740,9 +2838,9 @@ isa = PBXGroup; children = ( 2C963BCB2812D9330036DD53 /* ProfileSettingsAssembly.swift */, - 2C963BC92812D3550036DD53 /* ProfileSettingsView.swift */, E9F59B8F289FE053001CEA02 /* ProfileSettingsViewModel.swift */, 2C106D9A28C1CFA1004FA584 /* Controllers */, + 2CC63AED2B70B25800407810 /* Views */, ); path = ProfileSettings; sourceTree = ""; @@ -2846,6 +2944,7 @@ 2C9B66E827ECAF0200569645 /* Extensions */ = { isa = PBXGroup; children = ( + 2C0409822B863E8A00E9CF41 /* Combine */, 2C1F586C280D09A100372A37 /* Foundation */, 2C023C89285DC9F800D2D5A9 /* Shared */, 2CF4340E28126886002893CD /* SwiftStdlib */, @@ -2862,6 +2961,7 @@ 2C5B2A24286596A80097B270 /* UICollectionView+RegisterReusable.swift */, 2C58DE282803D197002A2774 /* UIColor+DynamicColor.swift */, 2C20FBAF284F1D8B006D879E /* UIColor+Hex.swift */, + 2C2CCB482B74FA6600D1E596 /* UIFont+PreferredFont.swift */, 2C7CB6812ADFDB45006F78DA /* UIFont+SizeOfString.swift */, 2CDF14D728EF1E080060D972 /* UINavigationControllerExtensions.swift */, 2C5B2A22286596400097B270 /* UITableView+RegisterReusable.swift */, @@ -2985,6 +3085,17 @@ path = ProblemOnboarding; sourceTree = ""; }; + 2CACBCBA2B7A1292006D3AB2 /* Widget */ = { + isa = PBXGroup; + children = ( + 2CACBCC12B7A3E4E006D3AB2 /* UsersQuestionnaireWidgetAssembly.swift */, + 2CACBCBF2B7A137A006D3AB2 /* UsersQuestionnaireWidgetOutputProtocol.swift */, + 2CACBCBB2B7A12F1006D3AB2 /* UsersQuestionnaireWidgetView.swift */, + 2CACBCBD2B7A1365006D3AB2 /* UsersQuestionnaireWidgetViewModel.swift */, + ); + path = Widget; + sourceTree = ""; + }; 2CAE8CEE280525A100E6C83D /* Step */ = { isa = PBXGroup; children = ( @@ -3087,6 +3198,15 @@ path = Routers; sourceTree = ""; }; + 2CB559302B87024000D949CB /* UsersQuestionnaire */ = { + isa = PBXGroup; + children = ( + C054D95AE78230BD225678B6 /* Onboarding */, + 2CACBCBA2B7A1292006D3AB2 /* Widget */, + ); + path = UsersQuestionnaire; + sourceTree = ""; + }; 2CBC97C42A5543190078E445 /* StageCompleted */ = { isa = PBXGroup; children = ( @@ -3137,6 +3257,7 @@ 2C43CDF828B55CC600E74762 /* HyperskillLogoView.swift */, E9D537D12A71330A00F21828 /* LinearGradientProgressView.swift */, 2C32375228380C340062CAF6 /* NavigationToolbarInfoItem.swift */, + 2C1B71042B6CB7D9003FD4A1 /* OffsetObservingScrollView.swift */, E9C3506E2886D0600080D277 /* OpenURLInsideAppButton.swift */, E9101712283296F3002E70F5 /* RadioButton.swift */, 2CAF254B2AB9C2E500595582 /* ShineEffect.swift */, @@ -3178,6 +3299,15 @@ path = Web; sourceTree = ""; }; + 2CC63AED2B70B25800407810 /* Views */ = { + isa = PBXGroup; + children = ( + 2CC63AEB2B70B25200407810 /* ProfileSettingsSubscriptionSectionView.swift */, + 2C963BC92812D3550036DD53 /* ProfileSettingsView.swift */, + ); + path = Views; + sourceTree = ""; + }; 2CC78D0A28C74EAF0006EF91 /* UIViewControllerEvents */ = { isa = PBXGroup; children = ( @@ -3246,6 +3376,15 @@ path = Modals; sourceTree = ""; }; + 2CD20ED22B73481800FB5269 /* ApplicationShortcuts */ = { + isa = PBXGroup; + children = ( + 2CD20ED32B73484200FB5269 /* ApplicationShortcutIdentifier.swift */, + 2CD20ED02B73475400FB5269 /* ApplicationShortcutsService.swift */, + ); + path = ApplicationShortcuts; + sourceTree = ""; + }; 2CD3652328796C3000D61855 /* ViewData */ = { isa = PBXGroup; children = ( @@ -3305,6 +3444,7 @@ 2CDF14D328EEFA850060D972 /* ViewControllers */ = { isa = PBXGroup; children = ( + 2C9ACAA52B870E3D00FE63FA /* AppRouter.swift */, 2CA368E428EEAC39004F7FD8 /* AppViewController.swift */, 2CDF14D428EEFA9C0060D972 /* TabBar */, ); @@ -3364,6 +3504,14 @@ path = App; sourceTree = ""; }; + 2CE4F0712B71D347001FD376 /* SubscriptionDetails */ = { + isa = PBXGroup; + children = ( + 2CE4F0722B71D358001FD376 /* SubscriptionDetailsView.swift */, + ); + path = SubscriptionDetails; + sourceTree = ""; + }; 2CE58C5D2B07690700E5EBBE /* ProgressGrid */ = { isa = PBXGroup; children = ( @@ -3697,6 +3845,17 @@ path = iosHyperskillApp; sourceTree = ""; }; + 9C3B04E6CBDCDDFE888D5DAF /* RequestReview */ = { + isa = PBXGroup; + children = ( + 4FF61AAE06DC019B8C49543C /* RequestReviewModalAssembly.swift */, + 2C2CCB462B74E71600D1E596 /* RequestReviewModalView.swift */, + 2C2CCB432B74D0E800D1E596 /* RequestReviewModalViewController.swift */, + 46D1A5B08EE626D2D612CEAE /* RequestReviewModalViewModel.swift */, + ); + path = RequestReview; + sourceTree = ""; + }; B58361EACE24BF4B761F10BA /* NotificationsOnboarding */ = { isa = PBXGroup; children = ( @@ -3716,6 +3875,17 @@ name = Frameworks; sourceTree = ""; }; + C054D95AE78230BD225678B6 /* Onboarding */ = { + isa = PBXGroup; + children = ( + D80660C909D729D12FEAB845 /* UsersQuestionnaireOnboardingAssembly.swift */, + 2C0409802B85FC3000E9CF41 /* UsersQuestionnaireOnboardingOutputProtocol.swift */, + 772BEE130815F1450D253FE3 /* UsersQuestionnaireOnboardingViewModel.swift */, + 2C195D072B8483EA0076B2C8 /* Views */, + ); + path = Onboarding; + sourceTree = ""; + }; E6992F3BBF430924F32DC178 /* Leaderboard */ = { isa = PBXGroup; children = ( @@ -4376,6 +4546,7 @@ E9886D3228ABCE5C003724F9 /* WelcomeOutputProtocol.swift in Sources */, 2C5CBBE12948EBEA00113007 /* StepQuizSQLViewDataMapper.swift in Sources */, 2CF4341228126C79002893CD /* View+EndEditing.swift in Sources */, + 2C2CCB472B74E71600D1E596 /* RequestReviewModalView.swift in Sources */, E9F2CC5329223C0200691540 /* StyledHostingController.swift in Sources */, E94F4C152923B46200DE0F7F /* TopicsRepetitionsChartAxis.swift in Sources */, 2C05AC5A2A0ECED90039C7EF /* ProjectSelectionListFeatureViewStateContent+Placeholder.swift in Sources */, @@ -4405,6 +4576,7 @@ E94BB04C2A9DFCCF00736B7C /* StepQuizParsonsViewData.swift in Sources */, 2C3100532AB194A200C09BFB /* StepQuizParsonsViewDataMapperCodeContentCache.swift in Sources */, 2CE8EE712B066D05004EB545 /* ChallengeWidgetViewStateContentCollectRewardButtonStateKsExtensions.swift in Sources */, + 2CE4F0732B71D358001FD376 /* SubscriptionDetailsView.swift in Sources */, 2CDA984929445C0A00ADE539 /* ProfileStatisticsItemView.swift in Sources */, E9F655D12875B32700291143 /* ProblemOfDayCardView.swift in Sources */, E99CD0BC292B9A2D00620259 /* TopicsRepetitionsViewModel.swift in Sources */, @@ -4456,7 +4628,9 @@ 2C0DFB9A2923BB4300D30921 /* ProfileFeatureStateKsExtensions.swift in Sources */, E9C3506F2886D0600080D277 /* OpenURLInsideAppButton.swift in Sources */, 2C23C0062879EA7D0083709F /* StreakDayState.swift in Sources */, + 2C7271242B6B634F005628B0 /* View+Task.swift in Sources */, 2C963BC72812D1BF0036DD53 /* HomeAssembly.swift in Sources */, + 2C0409842B863EA600E9CF41 /* Publishers+KeyboardIsVisible.swift in Sources */, 2C1860FC2923C540007D4EBF /* AppFeatureStateKsExtensions.swift in Sources */, 2C4FBD8E2876C94800ACA5C8 /* ProfileAboutSocialAccountsView.swift in Sources */, 2C05AC542A0EC5B00039C7EF /* ProjectSelectionListHeaderView.swift in Sources */, @@ -4492,14 +4666,17 @@ 2C5CA2412A20242E00DBF2F9 /* ProjectSelectionDetailsContentView.swift in Sources */, 2C5CA23E2A2022CB00DBF2F9 /* ProjectSelectionDetailsProviderView.swift in Sources */, 2CFD32442AAEFC4D00B9B6EA /* IosFCMTokenProviderImpl.swift in Sources */, + 2CD20ED12B73475400FB5269 /* ApplicationShortcutsService.swift in Sources */, 2C5F4A5A2971C71200677530 /* GamificationToolbarContent.swift in Sources */, 2C5B2A1F286595AF0097B270 /* CodeCompletionTableViewController.swift in Sources */, 2C8E4FB12848C9050011ADFA /* StepQuizTableSelectColumnsView.swift in Sources */, 2CCCA3A12862E62F00D98089 /* StepQuizStringViewData.swift in Sources */, 2C1061A2285C349400EBD614 /* StepQuizChildQuizAssembly.swift in Sources */, + 2CD4EDF92B79D51E0091F0B2 /* View+SafeAreaInset.swift in Sources */, 2C11D5CA2A11311900C59238 /* FeedbackGeneratorPreviewView.swift in Sources */, 2C971B852AC2F5DC00868FCE /* StepExpandableStepTextView.swift in Sources */, 2CB279AF28C72AA400EDDCC8 /* DeepLinkRouterProtocol.swift in Sources */, + 2C7271282B6B92AD005628B0 /* PaywallFooterView.swift in Sources */, 2C023C86285D927A00D2D5A9 /* StepQuizTableAssembly.swift in Sources */, 2C20FBC4284F67F3006D879E /* ProcessedContentWebView.swift in Sources */, 2C4F639B2A101DCE00D4EE39 /* ProjectSelectionListGridView.swift in Sources */, @@ -4508,6 +4685,7 @@ 2C005DCC27EF5B0300DC6503 /* GoogleServiceInfo.swift in Sources */, 2CBD191D291D3BF400F5FB0B /* UIKitRoundedRectangleButton.swift in Sources */, 2C078CE92AE29D0600D97E24 /* StepQuizFillBlanksViewDataMapperCache.swift in Sources */, + 2C9ACAA62B870E3D00FE63FA /* AppRouter.swift in Sources */, E9A6250F28ABAE83009423EE /* WelcomeViewModel.swift in Sources */, 2C0F3CFC2A80A47600947C35 /* BadgeDetailsModalView.swift in Sources */, E97BEA1E2977D26F00348EEC /* TopicCompletedModalViewController.swift in Sources */, @@ -4553,6 +4731,7 @@ 2C688C052A4E97750061AFFD /* ProgressScreenProjectProgressView.swift in Sources */, E9523BF429DAA5690013A661 /* StudyPlanSkeletonView.swift in Sources */, 2C2B7DD22946EF2800FAB55D /* WebViewNavigationController.swift in Sources */, + 2CACBCC02B7A137A006D3AB2 /* UsersQuestionnaireWidgetOutputProtocol.swift in Sources */, 2C336D242865E38B00C91342 /* CodeInputAccessoryCollectionViewCell.swift in Sources */, 2C2FD62428192123004E7AF6 /* BundlePropertyListDeserializer.swift in Sources */, 2C8E66D7287877F500D3928D /* ProfileViewModel.swift in Sources */, @@ -4577,6 +4756,7 @@ 2C93C2D8292EBBB5004D1861 /* AuthSocialFeatureStateKsExtensions.swift in Sources */, 2C4D6EF42AEF9ECC000064C7 /* StepQuizFillBlanksSkeletonView.swift in Sources */, E9101713283296F3002E70F5 /* RadioButton.swift in Sources */, + 2C2CCB492B74FA6600D1E596 /* UIFont+PreferredFont.swift in Sources */, 2C68FD7C2ABC1FF700D9EBE2 /* NotificationsOnboardingContentView.swift in Sources */, 2C20FBC2284F66FC006D879E /* NSAttributedString+TrimmingCharacters.swift in Sources */, 2C20B28A286C350C000F458A /* CodeEditor.swift in Sources */, @@ -4599,9 +4779,11 @@ 2C9674302888242D0091B6C9 /* StepQuizCodeViewData.swift in Sources */, 2C66720F2A529A7C0040EA2F /* ProgressScreenTrackProgressSkeletonView.swift in Sources */, 2C1F5888280D5D6200372A37 /* SocialAuthSDKProvider.swift in Sources */, + 2CACBCBE2B7A1365006D3AB2 /* UsersQuestionnaireWidgetViewModel.swift in Sources */, 2CAE8CF728052F9600E6C83D /* StepHeaderView.swift in Sources */, 2CF87DA229B718800092FF83 /* IntrospectViewController.swift in Sources */, 2CFD32462AAEFC6C00B9B6EA /* AppGraph+DefaultInstances.swift in Sources */, + 2C195D042B8467EA0076B2C8 /* UsersQuestionnaireOnboardingContentView.swift in Sources */, 2C96744228883A180091B6C9 /* StepQuizCodeViewDataMapper.swift in Sources */, 2C9E5E8629B215CA003AEC16 /* StageImplementViewModel.swift in Sources */, 2CAF254C2AB9C2E500595582 /* ShineEffect.swift in Sources */, @@ -4633,6 +4815,7 @@ 2C3796122877001700C197E2 /* ProfileHeaderView.swift in Sources */, 2C725B5E28090D1F00A49043 /* View+Border.swift in Sources */, 2C6672092A5297250040EA2F /* ProgressScreenProjectProgressSkeletonView.swift in Sources */, + 2C1B71052B6CB7D9003FD4A1 /* OffsetObservingScrollView.swift in Sources */, 2C8E4F9C2848A1550011ADFA /* PanModalViewModifier.swift in Sources */, 2CA7B88F2893295A00A789EF /* CodeEditorSuggestionsPresentationContextProviding.swift in Sources */, 2C8E4F9A284897360011ADFA /* PanModalSwiftUIViewController.swift in Sources */, @@ -4689,9 +4872,11 @@ 2C906CBE280E5D9C0079C594 /* ProgressHUD.swift in Sources */, 2CBC97D02A555BE60078E445 /* HypercoinsAwardView.swift in Sources */, 2CA854DA2B1DE4780045CA1B /* LeaderboardPlaceholderEmptyView.swift in Sources */, + 2CD4EDFB2B79D74B0091F0B2 /* TransparentBlurView.swift in Sources */, 2CA8E094281039EB00154088 /* RoundedRectangleButtonStyle.swift in Sources */, E9D537D02A71056100F21828 /* ProfileBadgesGridItemView.swift in Sources */, 2CB0ADEE2B04AD6D0089D557 /* ChallengeWidgetViewModel.swift in Sources */, + 2CACBCC22B7A3E4E006D3AB2 /* UsersQuestionnaireWidgetAssembly.swift in Sources */, E9CC6C0729893F2200D8D070 /* StepQuizInputProtocol.swift in Sources */, 2C96743728882A0C0091B6C9 /* StepQuizCodeDetailsView.swift in Sources */, 2C20FBC7284F6928006D879E /* ProgrammaticallyInitializableViewProtocol.swift in Sources */, @@ -4732,6 +4917,7 @@ E900D10328434E0D00A77BBC /* StepQuizSortingItemView.swift in Sources */, 2C54E42F2A1FAA88003406B9 /* TrackSelectionDetailsProvidersView.swift in Sources */, E996D41029221F0E00A47498 /* TopicsRepetitionsRepeatBlock.swift in Sources */, + 2C195D092B84863F0076B2C8 /* UsersQuestionnaireOnboardingChoicesView.swift in Sources */, E96E80F627EF57BA00AA6683 /* AuthSocialViewModel.swift in Sources */, E9A22BA0295081BD001700B7 /* StreakFreezeModalViewController.swift in Sources */, 2CBD1917291D392400F5FB0B /* UIView+Animations.swift in Sources */, @@ -4770,6 +4956,7 @@ 2CD48D8B2858684100CFCC4A /* StepQuizViewData.swift in Sources */, 2CB0ADF52B04BC8E0089D557 /* ChallengeWidgetAssembly.swift in Sources */, 2C05AC572A0EC9E50039C7EF /* ProjectSelectionListHeaderSkeletonView.swift in Sources */, + 2CC63AEC2B70B25200407810 /* ProfileSettingsSubscriptionSectionView.swift in Sources */, E9FB89AC2893EA580011EFFB /* NotificationPermissionStatus.swift in Sources */, 2C4FBD8C2876C39C00ACA5C8 /* ProfileAboutView.swift in Sources */, 2C023C88285D928100D2D5A9 /* StepQuizTableViewModel.swift in Sources */, @@ -4797,6 +4984,7 @@ 2C5837A32B2844E20096B89B /* SearchPlaceholderSuggestionsView.swift in Sources */, 2CD316C028A3B2040002B2B2 /* ApplicationTheme+SharedTheme.swift in Sources */, 2C1061A4285C34C900EBD614 /* StepQuizChildQuizOutputProtocol.swift in Sources */, + 2C0409812B85FC3000E9CF41 /* UsersQuestionnaireOnboardingOutputProtocol.swift in Sources */, 2C20FBA4284F165A006D879E /* ProcessedContent.swift in Sources */, 2C66720D2A5299C30040EA2F /* ProgressScreenCardSkeletonView.swift in Sources */, 2C7CB67E2ADFDA62006F78DA /* FillBlanksInputContainerView.swift in Sources */, @@ -4804,7 +4992,7 @@ E94BB0482A9DF9DD00736B7C /* StepQuizParsonsView.swift in Sources */, E99CCB0B287E945300898BBF /* HomeViewModel.swift in Sources */, 2C7CB6782ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift in Sources */, - 2C0FA879292FD73400A37636 /* ProfileSettingsFeatureStateKsExtensions.swift in Sources */, + 2C0FA879292FD73400A37636 /* ProfileSettingsFeatureViewStateKsExtensions.swift in Sources */, 2C1061AA285C3C3300EBD614 /* StepQuizChoiceAssembly.swift in Sources */, 2CF72AA828477E0600E1C192 /* StepQuizTableRowView.swift in Sources */, 2C80D503288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift in Sources */, @@ -4831,6 +5019,7 @@ 2C93AF2529B34FE6004639E0 /* StepQuizPyCharmAssembly.swift in Sources */, 2CDA98412944512D00ADE539 /* ProfileSkeletonView.swift in Sources */, 2CEDE70729965B4D0032D399 /* RestartApplicationLocalNotification.swift in Sources */, + 2CACBCBC2B7A12F1006D3AB2 /* UsersQuestionnaireWidgetView.swift in Sources */, 2CDA98432944524D00ADE539 /* HomeSkeletonView.swift in Sources */, 2C9D493D29F07015000599AB /* StudyPlanSectionErrorView.swift in Sources */, 2C336D262865E39D00C91342 /* CodeInputAccessoryView.swift in Sources */, @@ -4877,6 +5066,7 @@ 2C186ADB2B46989700DADB26 /* TopicsRepetitionsCountView.swift in Sources */, 2CF7C1B12A8355D3006B07ED /* BadgeLockedImageView.swift in Sources */, 2CE8EE6F2B066C2F004EB545 /* ChallengeWidgetContentStateCollectRewardButton.swift in Sources */, + 2C2CCB442B74D0E800D1E596 /* RequestReviewModalViewController.swift in Sources */, 2C32375328380C340062CAF6 /* NavigationToolbarInfoItem.swift in Sources */, 2C406C372A440E8200FA838E /* BuildVariant+Current.swift in Sources */, 2CAA3C6A2AA9C7B6004F6CE6 /* LottieAnimations.swift in Sources */, @@ -4907,10 +5097,12 @@ 2C82BA322844B01D004C9013 /* PlaceholderView+Configurations.swift in Sources */, 2C25BFD52851F8F00036C689 /* UIColor+DesignSystem.swift in Sources */, 2C023C8D285DCA4300D2D5A9 /* DatasetExtensions.swift in Sources */, + 2CD20ED42B73484200FB5269 /* ApplicationShortcutIdentifier.swift in Sources */, 2C186ADF2B46A0A300DADB26 /* InterviewPreparationCompletedModalView.swift in Sources */, E91017152832975C002E70F5 /* CheckboxButton.swift in Sources */, 2C99B1002A14255F0018627B /* StudyPlanWidgetViewStateSectionItemStateWrapper.swift in Sources */, E9E964872A0B8D8200841DF6 /* StepQuizProblemsLimitView.swift in Sources */, + 2C9320F52B68F14100999992 /* PaywallView.swift in Sources */, 2C8CD9AE2994EFC5008DC09D /* DebugFeatureViewStateKsExtensions.swift in Sources */, 2C83FBC02B177F68007AD7E2 /* LeaderboardListView.swift in Sources */, 2C967432288824370091B6C9 /* StepQuizCodeViewModel.swift in Sources */, @@ -4932,6 +5124,7 @@ 59B66CD4D1508049555D35AE /* ProgressScreenView.swift in Sources */, 2CE7B4842AB0593F00DCBE4D /* AttributedTextLabelWrapper.swift in Sources */, 2CE8EE6D2B065F00004EB545 /* ChallengeWidgetContentStateDeadlineView.swift in Sources */, + 2C7C0D632B6B45A20093609D /* PaywallFeaturesView.swift in Sources */, F759010A5FC990E99AAF0D76 /* ProgressScreenViewModel.swift in Sources */, DA48146596C2AB3F4E68208E /* NotificationsOnboardingAssembly.swift in Sources */, B2B30D0486FC13DCC80F4263 /* NotificationsOnboardingView.swift in Sources */, @@ -4939,12 +5132,19 @@ 043790C380B462AFEB2B13BC /* LeaderboardAssembly.swift in Sources */, BAEC674E5161E8C7A10ADAAB /* LeaderboardView.swift in Sources */, 60B4F143CF507F83C9581020 /* LeaderboardViewModel.swift in Sources */, + 2C195D062B8482840076B2C8 /* UsersQuestionnaireOnboardingFooterView.swift in Sources */, 8E154CD6AF7D45A2CA013F85 /* SearchAssembly.swift in Sources */, 7A628C36D862C98ED2046D4F /* SearchView.swift in Sources */, ED49113F88FF32AAFE6AFFBC /* SearchViewModel.swift in Sources */, 4F5F2FD2F3BCAC06612FCAE8 /* InterviewPreparationOnboardingAssembly.swift in Sources */, 0C3BB55AA2B8FB7F5ED9CADB /* InterviewPreparationOnboardingView.swift in Sources */, 63FC2C36279DBA43CCEA1360 /* InterviewPreparationOnboardingViewModel.swift in Sources */, + 0F98394636E12DEC98B7953A /* RequestReviewModalAssembly.swift in Sources */, + 2C829B912B88583300765335 /* StepQuizUnsupportedView.swift in Sources */, + 91046416561EE431760D7D48 /* RequestReviewModalViewModel.swift in Sources */, + 09A3FA31C3ABC65467D36662 /* UsersQuestionnaireOnboardingAssembly.swift in Sources */, + 1CAC118437C9C9910D39009E /* UsersQuestionnaireOnboardingView.swift in Sources */, + 4FBE20D41C99246C44E068AA /* UsersQuestionnaireOnboardingViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4985,7 +5185,7 @@ buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 306; + CURRENT_PROJECT_VERSION = 349; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 3DWS674B2M; GENERATE_INFOPLIST_FILE = NO; @@ -5006,7 +5206,7 @@ buildSettings = { CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 306; + CURRENT_PROJECT_VERSION = 349; DEVELOPMENT_TEAM = 3DWS674B2M; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = iosHyperskillAppUITests/Info.plist; @@ -5027,7 +5227,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 306; + CURRENT_PROJECT_VERSION = 349; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 3DWS674B2M; INFOPLIST_FILE = iosHyperskillAppTests/Info.plist; @@ -5048,7 +5248,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 306; + CURRENT_PROJECT_VERSION = 349; DEVELOPMENT_TEAM = 3DWS674B2M; INFOPLIST_FILE = iosHyperskillAppTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -5069,7 +5269,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 306; + CURRENT_PROJECT_VERSION = 349; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 3DWS674B2M; INFOPLIST_FILE = NotificationServiceExtension/Info.plist; @@ -5097,7 +5297,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 306; + CURRENT_PROJECT_VERSION = 349; DEVELOPMENT_TEAM = 3DWS674B2M; INFOPLIST_FILE = NotificationServiceExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; @@ -5242,7 +5442,7 @@ CODE_SIGN_ENTITLEMENTS = iosHyperskillApp/iosHyperskillApp.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 306; + CURRENT_PROJECT_VERSION = 349; DEVELOPMENT_ASSET_PATHS = "\"iosHyperskillApp/Preview Content\""; DEVELOPMENT_TEAM = 3DWS674B2M; ENABLE_PREVIEWS = YES; @@ -5278,7 +5478,7 @@ CODE_SIGN_ENTITLEMENTS = iosHyperskillApp/iosHyperskillApp.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 306; + CURRENT_PROJECT_VERSION = 349; DEVELOPMENT_ASSET_PATHS = "\"iosHyperskillApp/Preview Content\""; DEVELOPMENT_TEAM = 3DWS674B2M; ENABLE_PREVIEWS = YES; diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Gradients/Contents.json b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Gradients/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Gradients/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Gradients/brand-gradient-3.imageset/Contents.json b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Gradients/brand-gradient-3.imageset/Contents.json new file mode 100644 index 0000000000..58bda5eef0 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Gradients/brand-gradient-3.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "brand-gradient-3.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Gradients/brand-gradient-3.imageset/brand-gradient-3.pdf b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Gradients/brand-gradient-3.imageset/brand-gradient-3.pdf new file mode 100644 index 0000000000..b334d4d0b0 Binary files /dev/null and b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Gradients/brand-gradient-3.imageset/brand-gradient-3.pdf differ diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/Contents.json b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/paywall-premium-mobile.imageset/Contents.json b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/paywall-premium-mobile.imageset/Contents.json new file mode 100644 index 0000000000..6bcbd4bb0e --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/paywall-premium-mobile.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "paywall-premium-mobile.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "paywall-premium-mobile@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "paywall-premium-mobile@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/paywall-premium-mobile.imageset/paywall-premium-mobile.png b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/paywall-premium-mobile.imageset/paywall-premium-mobile.png new file mode 100644 index 0000000000..c8cc5f58ef Binary files /dev/null and b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/paywall-premium-mobile.imageset/paywall-premium-mobile.png differ diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/paywall-premium-mobile.imageset/paywall-premium-mobile@2x.png b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/paywall-premium-mobile.imageset/paywall-premium-mobile@2x.png new file mode 100644 index 0000000000..42f20e08ef Binary files /dev/null and b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/paywall-premium-mobile.imageset/paywall-premium-mobile@2x.png differ diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/paywall-premium-mobile.imageset/paywall-premium-mobile@3x.png b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/paywall-premium-mobile.imageset/paywall-premium-mobile@3x.png new file mode 100644 index 0000000000..9a6184b873 Binary files /dev/null and b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/Paywall/paywall-premium-mobile.imageset/paywall-premium-mobile@3x.png differ diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/StepQuiz/step-quiz-unsupported-illustration.imageset/Contents.json b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/StepQuiz/step-quiz-unsupported-illustration.imageset/Contents.json new file mode 100644 index 0000000000..1cf4914ec5 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/StepQuiz/step-quiz-unsupported-illustration.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "step-quiz-unsupported-illustration.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/StepQuiz/step-quiz-unsupported-illustration.imageset/step-quiz-unsupported-illustration.pdf b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/StepQuiz/step-quiz-unsupported-illustration.imageset/step-quiz-unsupported-illustration.pdf new file mode 100644 index 0000000000..094e0c7e4e Binary files /dev/null and b/iosHyperskillApp/iosHyperskillApp/Assets.xcassets/StepQuiz/step-quiz-unsupported-illustration.imageset/step-quiz-unsupported-illustration.pdf differ diff --git a/iosHyperskillApp/iosHyperskillApp/Info.plist b/iosHyperskillApp/iosHyperskillApp/Info.plist index 8e79dfe900..6de951c0d2 100644 --- a/iosHyperskillApp/iosHyperskillApp/Info.plist +++ b/iosHyperskillApp/iosHyperskillApp/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.49.1 + 1.50 CFBundleURLTypes @@ -36,7 +36,7 @@ CFBundleVersion - 306 + 349 FirebaseAppDelegateProxyEnabled FirebaseMessagingAutoInitEnabled @@ -69,5 +69,25 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + UIApplicationShortcutItems + + + UIApplicationShortcutItemType + $(PRODUCT_BUNDLE_IDENTIFIER).SendFeedback + UIApplicationShortcutItemTitle + We Value Your Feedback + UIApplicationShortcutItemSubtitle + Help us enhance your experience + UIApplicationShortcutItemIconSymbolName + lightbulb.circle.fill + UIApplicationShortcutItemIconType + UIApplicationShortcutIconTypeLove + UIApplicationShortcutItemUserInfo + + version + 1 + + + diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/AppDelegate.swift b/iosHyperskillApp/iosHyperskillApp/Sources/AppDelegate.swift index 65d732c863..cca4ab857f 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/AppDelegate.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/AppDelegate.swift @@ -13,6 +13,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private lazy var notificationPermissionStatusSettingsObserver = NotificationPermissionStatusSettingsObserver.default private lazy var notificationsRegistrationService = NotificationsRegistrationService.shared + private lazy var applicationShortcutsService: ApplicationShortcutsServiceProtocol = ApplicationShortcutsService() + // MARK: Initializing the App func application( @@ -48,6 +50,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { userNotificationsCenterDelegate.attachNotificationDelegate() notificationPermissionStatusSettingsObserver.startObserving() + // If app launched using a quick action, perform the requested quick action and return a value of false + // to prevent call the application:performActionForShortcutItem:completionHandler: method. + if applicationShortcutsService.handleLaunchOptions(launchOptions) { + return false + } + return true } @@ -89,6 +97,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { notificationsService.handleLocalNotification(with: notification.userInfo) } + // MARK: Continuing User Activity and Handling Quick Actions + + func application( + _ application: UIApplication, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void + ) { + completionHandler(applicationShortcutsService.handleShortcutItem(shortcutItem)) + } + // MARK: Opening a URL-Specified Resource func application( diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Combine/Publishers+KeyboardIsVisible.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Combine/Publishers+KeyboardIsVisible.swift new file mode 100644 index 0000000000..832728bc82 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Combine/Publishers+KeyboardIsVisible.swift @@ -0,0 +1,15 @@ +import Combine +import UIKit + +extension Publishers { + static var keyboardIsVisible: AnyPublisher { + let willShow = NotificationCenter.default.publisher(for: UIApplication.keyboardWillShowNotification) + .map { _ in true } + + let willHide = NotificationCenter.default.publisher(for: UIApplication.keyboardWillHideNotification) + .map { _ in false } + + return MergeMany(willShow, willHide) + .eraseToAnyPublisher() + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/BlockOptionsExtensions.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/BlockOptionsExtensions.swift index 5c590c6b89..907a78a3a3 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/BlockOptionsExtensions.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/BlockOptionsExtensions.swift @@ -2,6 +2,7 @@ import Foundation import shared extension Block.Options { + // swiftlint:disable discouraged_optional_boolean convenience init( isMultipleChoice: Bool? = nil, language: String? = nil, @@ -12,14 +13,14 @@ extension Block.Options { files: [Block.OptionsFile]? = nil ) { let isMultipleChoice: KotlinBoolean? = { - if let isMultipleChoice = isMultipleChoice { + if let isMultipleChoice { return KotlinBoolean(value: isMultipleChoice) } return nil }() let isCheckbox: KotlinBoolean? = { - if let isCheckbox = isCheckbox { + if let isCheckbox { return KotlinBoolean(value: isCheckbox) } return nil @@ -35,4 +36,5 @@ extension Block.Options { files: files ) } + // swiftlint:enable discouraged_optional_boolean } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/ReplyExtensions.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/ReplyExtensions.swift index 46afa7b002..2f5be772c6 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/ReplyExtensions.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/Shared/Model/ReplyExtensions.swift @@ -21,9 +21,9 @@ extension Reply { lines: [ParsonsLine]? = nil ) { let choicesAnswer: [ChoiceAnswer]? = { - if let sortingChoices = sortingChoices { + if let sortingChoices { return sortingChoices.map(ChoiceAnswerChoice.init(boolValue:)) - } else if let tableChoices = tableChoices { + } else if let tableChoices { return tableChoices.map(ChoiceAnswerTable.init(tableChoice:)) } return nil diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/SwiftUI/View/View+SafeAreaInset.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/SwiftUI/View/View+SafeAreaInset.swift new file mode 100644 index 0000000000..1be5706323 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/SwiftUI/View/View+SafeAreaInset.swift @@ -0,0 +1,18 @@ +import SwiftUI + +@available(iOS, introduced: 13, deprecated: 15, message: "Use .safeAreaInset() directly") +extension View { + @ViewBuilder + func safeAreaInsetBottomCompatibility(_ content: Content) -> some View { + if #available(iOS 15.0, *) { + safeAreaInset( + edge: .bottom, + alignment: .center, + spacing: 0, + content: { content } + ) + } else { + overlay(content, alignment: .bottom) + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/SwiftUI/View/View+Task.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/SwiftUI/View/View+Task.swift new file mode 100644 index 0000000000..9aa9e248fe --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/SwiftUI/View/View+Task.swift @@ -0,0 +1,37 @@ +import SwiftUI + +private struct TaskCompatibilityModifier: ViewModifier { + let priority: TaskPriority + + let action: @Sendable () async -> Void + + @State var task: Task? + + func body(content: Content) -> some View { + content + .onAppear { + if task != nil { + task?.cancel() + } + task = Task(priority: priority, operation: action) + } + .onDisappear { + task?.cancel() + } + } +} + +extension View { + @ViewBuilder + @available(iOS, deprecated: 15.0) + func taskCompatibility( + priority: TaskPriority = .userInitiated, + _ action: @escaping @Sendable () async -> Void + ) -> some View { + if #available(iOS 15.0, *) { + self.task(priority: priority, action) + } else { + self.modifier(TaskCompatibilityModifier(priority: priority, action: action)) + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIFont+PreferredFont.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIFont+PreferredFont.swift new file mode 100644 index 0000000000..074f2557af --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIFont+PreferredFont.swift @@ -0,0 +1,10 @@ +import UIKit + +extension UIFont { + static func preferredFont(for style: TextStyle, weight: Weight) -> UIFont { + let metrics = UIFontMetrics(forTextStyle: style) + let desc = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style) + let font = UIFont.systemFont(ofSize: desc.pointSize, weight: weight) + return metrics.scaledFont(for: font) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIWindowExtensions.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIWindowExtensions.swift index 18a87b9ccc..4d092f544a 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIWindowExtensions.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIWindowExtensions.swift @@ -34,7 +34,7 @@ extension UIWindow { /// - responder: The view object, `UIView`, `UIViewController`, or `UIWindow` instance. /// - level: The depth level in the view hierarchy. func traverseHierarchy(_ visitor: (_ responder: UIResponder, _ level: Int) -> Void) { - /// Stack used to accumulate objects to visit. + // Stack used to accumulate objects to visit. var stack: [(responder: UIResponder, level: Int)] = [(responder: self, level: 0)] while !stack.isEmpty { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/Model/Analyze/CodePlaygroundManager.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/Model/Analyze/CodePlaygroundManager.swift index 60a79b4208..5aebb8c6ca 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/Model/Analyze/CodePlaygroundManager.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/Model/Analyze/CodePlaygroundManager.swift @@ -139,6 +139,7 @@ final class CodePlaygroundManager { return beforeCursorString + afterCursorString } + // swiftlint:disable:next function_parameter_count private func checkNextLineInsertion( currentText: String, previousText: String, @@ -226,6 +227,7 @@ final class CodePlaygroundManager { } } + // swiftlint:disable:next function_parameter_count private func checkPaired( currentText: String, previousText: String, @@ -372,6 +374,7 @@ final class CodePlaygroundManager { currentCodeCompletionTableViewController = nil } + // swiftlint:disable:next function_parameter_count private func presentCodeCompletion( suggestions: [String], prefix: String, @@ -430,17 +433,23 @@ final class CodePlaygroundManager { return } - let cursorPosition = textView.offset(from: textView.beginningOfDocument, to: selectedRange.start) - var text = textView.text ?? "" - text.insert(contentsOf: symbols, at: text.index(text.startIndex, offsetBy: cursorPosition)) - textView.text = text - // Import here to update selectedTextRange before calling textViewDidChange #APPS-2352 - textView.selectedTextRange = textRangeFrom(position: cursorPosition + symbols.count, textView: textView) + if selectedRange.isEmpty { + let cursorPosition = textView.offset(from: textView.beginningOfDocument, to: selectedRange.start) + var text = textView.text ?? "" + text.insert(contentsOf: symbols, at: text.index(text.startIndex, offsetBy: cursorPosition)) + textView.text = text + // Import here to update selectedTextRange before calling textViewDidChange #APPS-2352 + textView.selectedTextRange = textRangeFrom(position: cursorPosition + symbols.count, textView: textView) + } else { + textView.replace(selectedRange, withText: symbols) + } + // Manually call textViewDidChange, because when manually setting the text of a UITextView with code, // the textViewDidChange: method does not get called. textView.delegate?.textViewDidChange?(textView) } + // swiftlint:disable:next function_parameter_count func analyzeAndComplete( textView: UITextView, previousText: String, diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/SwiftUI/CodeEditor.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/SwiftUI/CodeEditor.swift index 046ecc5e1b..909991a315 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/SwiftUI/CodeEditor.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/SwiftUI/CodeEditor.swift @@ -78,7 +78,7 @@ struct CodeEditor: UIViewRepresentable { self.code = newCode } context.coordinator.onDidBeginEditing = { [weak codeEditorView] in - guard let codeEditorView = codeEditorView else { + guard let codeEditorView else { return } @@ -87,7 +87,7 @@ struct CodeEditor: UIViewRepresentable { onDidBeginEditing?() } context.coordinator.onDidEndEditing = { [weak codeEditorView] in - guard let codeEditorView = codeEditorView else { + guard let codeEditorView else { return } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/SwiftUI/CodeEditorSuggestionsPresentationContextProviding.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/SwiftUI/CodeEditorSuggestionsPresentationContextProviding.swift index 5a49b9c422..56c1d8948e 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/SwiftUI/CodeEditorSuggestionsPresentationContextProviding.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/SwiftUI/CodeEditorSuggestionsPresentationContextProviding.swift @@ -4,15 +4,12 @@ protocol CodeEditorSuggestionsPresentationContextProviding: AnyObject { func presentationController(for codeEditorView: CodeEditorView) -> UIViewController? } -// swiftlint:disable all - final class ResponderChainCodeEditorSuggestionsPresentationContextProvider: - CodeEditorSuggestionsPresentationContextProviding -{ + CodeEditorSuggestionsPresentationContextProviding { private weak var presentationController: UIViewController? func presentationController(for codeEditorView: CodeEditorView) -> UIViewController? { - if let presentationController = presentationController { + if let presentationController { return presentationController } else { let responsibleViewController = codeEditorView.findViewController() diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeCompletion/CodeCompletionTableViewCell/CodeCompletionCellView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeCompletion/CodeCompletionTableViewCell/CodeCompletionCellView.swift index f7e421bd11..9648df606f 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeCompletion/CodeCompletionTableViewCell/CodeCompletionCellView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeCompletion/CodeCompletionTableViewCell/CodeCompletionCellView.swift @@ -13,7 +13,7 @@ final class CodeCompletionCellView: UIView { private lazy var textLabel: UILabel = { let label = UILabel() - label.textColor = self.appearance.textColor + label.textColor = appearance.textColor label.numberOfLines = 1 return label }() diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeCompletion/CodeCompletionTableViewController.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeCompletion/CodeCompletionTableViewController.swift index 229b4320a4..c567662214 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeCompletion/CodeCompletionTableViewController.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeCompletion/CodeCompletionTableViewController.swift @@ -59,7 +59,7 @@ final class CodeCompletionTableViewController: UITableViewController { clearsSelectionOnViewWillAppear = false tableView.rowHeight = suggestionRowHeight - //Adding tap gesture recognizer to catch selection to avoid resignFirstResponder call and keyboard disappearance + // Adding tap gesture recognizer to catch selection to avoid resignFirstResponder call and keyboard disappearance let tapGestureRecognizer = UITapGestureRecognizer( target: self, action: #selector(didTap(recognizer:)) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeEditorView/CodeEditorView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeEditorView/CodeEditorView.swift index ee1026deb4..89f7924d52 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeEditorView/CodeEditorView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeEditorView/CodeEditorView.swift @@ -13,7 +13,7 @@ final class CodeEditorView: UIView { weak var delegate: CodeEditorViewDelegate? private lazy var codeTextView: CodeTextView = { - let codeTextView = CodeTextView(appearance: self.appearance.textViewAppearance) + let codeTextView = CodeTextView(appearance: appearance.textViewAppearance) codeTextView.delegate = self // Disable features codeTextView.autocapitalizationType = .none @@ -62,7 +62,7 @@ final class CodeEditorView: UIView { var theme: CodeEditorTheme? { didSet { - if let theme = theme { + if let theme { codeTextView.updateTheme(name: theme.name, font: theme.font) } } @@ -126,7 +126,7 @@ final class CodeEditorView: UIView { codeTextView.reloadInputViews() } - guard let language = language, isEditable else { + guard let language, isEditable else { codeTextView.inputAccessoryView = nil return } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeInputAccessory/CodeInputAccessoryBuilder.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeInputAccessory/CodeInputAccessoryBuilder.swift index ad5d6b69c5..4c4847f975 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeInputAccessory/CodeInputAccessoryBuilder.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeInputAccessory/CodeInputAccessoryBuilder.swift @@ -1,6 +1,7 @@ import UIKit enum CodeInputAccessoryBuilder { + // swiftlint:disable:next function_parameter_count static func buildAccessoryView( size: CodeInputAccessorySize, language: CodeLanguage, diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeInputAccessory/Toolbar/CodeInputAccessoryCollectionViewCell.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeInputAccessory/Toolbar/CodeInputAccessoryCollectionViewCell.swift index 99d04cac31..39c369163a 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeInputAccessory/Toolbar/CodeInputAccessoryCollectionViewCell.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeInputAccessory/Toolbar/CodeInputAccessoryCollectionViewCell.swift @@ -52,7 +52,7 @@ final class CodeInputAccessoryCollectionViewCell: UICollectionViewCell, Reusable label.numberOfLines = 1 label.textAlignment = .center - if let size = size { + if let size { label.font = makeFont(for: size) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeInputAccessory/Toolbar/CodeInputAccessoryView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeInputAccessory/Toolbar/CodeInputAccessoryView.swift index c35f19a37b..7355c20146 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeInputAccessory/Toolbar/CodeInputAccessoryView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeInputAccessory/Toolbar/CodeInputAccessoryView.swift @@ -34,7 +34,7 @@ final class CodeInputAccessoryView: UIView { imageView.isUserInteractionEnabled = true let tapGestureRecognizer = UITapGestureRecognizer( target: self, - action: #selector(self.didTapHideKeyboardImageView(recognizer:)) + action: #selector(didTapHideKeyboardImageView(recognizer:)) ) imageView.addGestureRecognizer(tapGestureRecognizer) return imageView @@ -47,8 +47,8 @@ final class CodeInputAccessoryView: UIView { private lazy var collectionView: UICollectionView = { let collectionViewLayout = UICollectionViewFlowLayout() collectionViewLayout.scrollDirection = .horizontal - collectionViewLayout.sectionInset = self.appearance.collectionViewLayoutSectionInset - collectionViewLayout.minimumInteritemSpacing = self.appearance.collectionViewLayoutMinimumInteritemSpacing + collectionViewLayout.sectionInset = appearance.collectionViewLayoutSectionInset + collectionViewLayout.minimumInteritemSpacing = appearance.collectionViewLayoutMinimumInteritemSpacing let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) collectionView.isPagingEnabled = false diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeTextView/CodeTextView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeTextView/CodeTextView.swift index 33e951e877..967b5856cd 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeTextView/CodeTextView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeTextView/CodeTextView.swift @@ -50,7 +50,7 @@ final class CodeTextView: UITextView { var language: String? { didSet { guard language != oldValue, - let codeAttributedString = codeAttributedString + let codeAttributedString else { return } @@ -164,7 +164,7 @@ final class CodeTextView: UITextView { } func updateTheme(name: String, font: UIFont) { - guard let codeAttributedString = codeAttributedString else { + guard let codeAttributedString else { return } @@ -179,7 +179,7 @@ final class CodeTextView: UITextView { } private func invalidateDisplayOfCurrentLine() { - guard let codeTextViewLayoutManager = codeTextViewLayoutManager else { + guard let codeTextViewLayoutManager else { return } @@ -219,7 +219,7 @@ final class CodeTextView: UITextView { appearance.gutterBorderColor = invertedThemeBackgroundColor.withAlphaComponent(alphas.gutterBorderColorAlpha) - guard let codeTextViewLayoutManager = codeTextViewLayoutManager else { + guard let codeTextViewLayoutManager else { return } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeTextView/CodeTextViewLayoutManager.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeTextView/CodeTextViewLayoutManager.swift index 71043eac14..09a48729c6 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeTextView/CodeTextViewLayoutManager.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/CodeEditor/View/UIKit/CodeTextView/CodeTextViewLayoutManager.swift @@ -104,7 +104,7 @@ final class CodeTextViewLayoutManager: NSLayoutManager { } } - guard let textStorage = textStorage else { + guard let textStorage else { return } @@ -128,7 +128,7 @@ final class CodeTextViewLayoutManager: NSLayoutManager { if characterRange.location == lastParagraphLocation { return lastParagraphNumber } else if characterRange.location < lastParagraphLocation { - guard let textStorage = textStorage else { + guard let textStorage else { return lastParagraphNumber } @@ -154,7 +154,7 @@ final class CodeTextViewLayoutManager: NSLayoutManager { return paragraphNumber } else { - guard let textStorage = textStorage else { + guard let textStorage else { return lastParagraphNumber } @@ -189,7 +189,7 @@ final class CodeTextViewLayoutManager: NSLayoutManager { } private func shouldHighlightParagraphRange(_ paragraphRange: NSRange) -> Bool { - guard shouldHighlightCurrentLine, let selectedRange = selectedRange else { + guard shouldHighlightCurrentLine, let selectedRange else { return false } return NSLocationInRange(selectedRange.location, paragraphRange) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Collections/LinkedList.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Collections/LinkedList.swift index 666aea4cb0..d296d58c80 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Collections/LinkedList.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Collections/LinkedList.swift @@ -34,7 +34,7 @@ final class LinkedList { let previous = node.previous let next = node.next - if let previous = previous { + if let previous { previous.next = next } else { self.head = next diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/ContentProcessor/Processing/ContentProcessingInjection.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/ContentProcessor/Processing/ContentProcessingInjection.swift index ca8524c93c..ad817d575f 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/ContentProcessor/Processing/ContentProcessingInjection.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/ContentProcessor/Processing/ContentProcessingInjection.swift @@ -99,6 +99,44 @@ final class ClickableImagesInjection: ContentProcessingInjection { } } +/// Removes all elements that are marked as hidden on mobile devices. +final class DataMobileHiddenElementsInjection: ContentProcessingInjection { + var headScript: String { + """ + + """ + } +} + +/// Removes all iframe elements. +final class RemoveInlineFrameElementsInjection: ContentProcessingInjection { + var headScript: String { + """ + + """ + } + + func shouldInject(to code: String) -> Bool { + code.contains(": ObservableObject { } if isListeningForChanges, - let onViewAction = onViewAction { + let onViewAction { mainScheduler.schedule { onViewAction(viewAction) } } else { viewActionQueue.enqueue(value: viewAction) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Notifications/Local/LocalNotificationsService.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Notifications/Local/LocalNotificationsService.swift index 91dc997e2e..d238d5ba5a 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Notifications/Local/LocalNotificationsService.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Notifications/Local/LocalNotificationsService.swift @@ -109,7 +109,7 @@ final class LocalNotificationsService { /// - fireDate: The Date object to be checked. /// - Returns: `true` if the `fireDate` exists and it in the future, otherwise false. private func isFireDateValid(_ fireDate: Date?) -> Bool { - if let fireDate = fireDate { + if let fireDate { return fireDate.compare(Date()) == .orderedDescending } else { return false diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Notifications/NotificationsService.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Notifications/NotificationsService.swift index 09d5296abf..2eb81986c8 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Notifications/NotificationsService.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Notifications/NotificationsService.swift @@ -89,7 +89,7 @@ extension NotificationsService { // MARK: Private Helpers private func reportReceivedLocalNotification(with userInfo: NotificationUserInfo?) { - guard let userInfo = userInfo, + guard let userInfo, let notificationName = userInfo[ LocalNotificationsService.PayloadKey.notificationName.rawValue ] as? String else { @@ -119,7 +119,7 @@ extension NotificationsService { } } - guard let userInfo = userInfo, + guard let userInfo, let notificationName = userInfo[ LocalNotificationsService.PayloadKey.notificationName.rawValue ] as? String else { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Notifications/Registration/NotificationsRegistrationService.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Notifications/Registration/NotificationsRegistrationService.swift index d829622c09..7474d6a52b 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Notifications/Registration/NotificationsRegistrationService.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/Notifications/Registration/NotificationsRegistrationService.swift @@ -248,7 +248,7 @@ extension NotificationsRegistrationService { } private func postCurrentPermissionStatus(_ permissionStatus: NotificationPermissionStatus? = nil) { - if let permissionStatus = permissionStatus { + if let permissionStatus { NotificationCenter.default.post( name: .notificationsRegistrationServiceDidUpdatePermissionStatus, object: permissionStatus @@ -340,7 +340,9 @@ extension NotificationsRegistrationService { extension NotificationsRegistrationService { private func logSystemNoticeShownEvent() { DispatchQueue.main.async { - let event = NotificationSystemNoticeShownHyperskillAnalyticEvent(route: HyperskillAnalyticRoute.Home()) + let event = NotificationSystemNoticeShownHyperskillAnalyticEvent( + route: HyperskillAnalyticRoute.None.shared + ) self.analyticInteractor.logEvent(event: event) } } @@ -348,7 +350,7 @@ extension NotificationsRegistrationService { private func logSystemNoticeHiddenEvent(isAllowed: Bool) { DispatchQueue.main.async { let event = NotificationSystemNoticeHiddenHyperskillAnalyticEvent( - route: HyperskillAnalyticRoute.Home(), + route: HyperskillAnalyticRoute.None.shared, isAllowed: isAllowed ) self.analyticInteractor.logEvent(event: event) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/WebController/WebViewController.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/WebController/WebViewController.swift index f07c0db33d..e61b13657d 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/WebController/WebViewController.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/WebController/WebViewController.swift @@ -233,3 +233,4 @@ class WebViewController: UIViewController { progressBar.setProgress(completed ? 0.0 : Float(webView.estimatedProgress), animated: !completed) } } +// swiftlint:enable all diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/ProfileSettingsFeatureStateKsExtensions.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/ProfileSettingsFeatureViewStateKsExtensions.swift similarity index 53% rename from iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/ProfileSettingsFeatureStateKsExtensions.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/ProfileSettingsFeatureViewStateKsExtensions.swift index ae22a8b685..7b085e223b 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/ProfileSettingsFeatureStateKsExtensions.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Frameworks/sharedSwift/Extensions/ProfileSettingsFeatureViewStateKsExtensions.swift @@ -1,41 +1,27 @@ import Foundation import shared -extension ProfileSettingsFeatureStateKs: Equatable { - public static func == (lhs: ProfileSettingsFeatureStateKs, rhs: ProfileSettingsFeatureStateKs) -> Bool { +extension ProfileSettingsFeatureViewStateKs: Equatable { + public static func == (lhs: ProfileSettingsFeatureViewStateKs, rhs: ProfileSettingsFeatureViewStateKs) -> Bool { switch (lhs, rhs) { case (.idle, .idle): return true case (.loading, .loading): return true - case (.error, .error): - return true case (.content(let lhsData), .content(let rhsData)): return lhsData.isEqual(rhsData) case (.content, .idle): return false case (.content, .loading): return false - case (.content, .error): - return false - case (.error, .idle): - return false - case (.error, .loading): - return false - case (.error, .content): - return false case (.loading, .idle): return false case (.loading, .content): return false - case (.loading, .error): - return false case (.idle, .loading): return false case (.idle, .content): return false - case (.idle, .error): - return false } } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Helpers/MainBundleInfo.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Helpers/MainBundleInfo.swift index 93eeec93ee..9ba93b1441 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Helpers/MainBundleInfo.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Helpers/MainBundleInfo.swift @@ -18,8 +18,8 @@ enum MainBundleInfo { /// Returns formatted version with build number string -> "1.0 (1)". /// Otherwise returns `nil` if missing `shortVersionString` or `buildNumberString`. static var shortVersionWithBuildNumberString: String? { - guard let shortVersionString = shortVersionString, - let buildNumberString = buildNumberString else { + guard let shortVersionString, + let buildNumberString else { return nil } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Images/Images.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Images/Images.swift index 9a5a368464..d1f6ef9f3b 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Images/Images.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Images/Images.swift @@ -1,6 +1,6 @@ import Foundation -//@available(*, deprecated, message: "Use Xcode 15 image resources instead") +// @available(*, deprecated, message: "Use Xcode 15 image resources instead") enum Images { // MARK: - Common - diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift index 64ced883c4..6b634da6ae 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift @@ -95,7 +95,9 @@ enum Strings { static let checkingButton = sharedStrings.step_quiz_checking_button_text.localized() static let discussionsButton = sharedStrings.step_quiz_discussions_button_text.localized() - static let unsupportedText = sharedStrings.step_quiz_unsupported_quiz_text.localized() + static let unsupportedTitle = sharedStrings.step_quiz_unsupported_quiz_title.localized() + static let unsupportedDescription = sharedStrings.step_quiz_unsupported_quiz_description.localized() + static let unsupportedButtonSolve = sharedStrings.step_quiz_unsupported_quiz_button_solve_text.localized() static let stepTextHeaderTitle = sharedStrings.step_quiz_step_text_header_title.localized() @@ -129,10 +131,6 @@ enum Strings { sharedStrings.step_quiz_topic_completed_continue_with_next_topic_button_text.localized() } - enum ProblemsLimitReachedModal { - static let title = sharedStrings.problems_limit_reached_modal_title.localized() - } - enum ShareStreakModal { static let title = sharedStrings.share_streak_modal_title.localized() static let shareButton = sharedStrings.share_streak_modal_share_button_text.localized() @@ -259,6 +257,24 @@ enum Strings { static let networkError = sharedStrings.challenge_widget_network_error_text.localized() } + // MARK: - Users questionnaire - + + // MARK: Widget + + enum UsersQuestionnaireWidget { + static let title = sharedStrings.users_questionnaire_widget_title.localized() + } + + // MARK: Onboarding + + enum UsersQuestionnaireOnboarding { + static let textInputPlaceholder = + sharedStrings.users_questionnaire_onboarding_text_input_placeholder.localized() + + static let sendButtot = sharedStrings.users_questionnaire_onboarding_send_button_text.localized() + static let skipButton = sharedStrings.users_questionnaire_onboarding_skip_button_text.localized() + } + // MARK: - Interview Preparation - // MARK: Widget @@ -350,7 +366,6 @@ enum Strings { static let reportProblem = sharedStrings.settings_report_problem.localized() static let sendFeedback = sharedStrings.settings_send_feedback.localized() static let version = sharedStrings.settings_version.localized() - static let rateApplication = sharedStrings.settings_rate_application.localized() static let signOut = sharedStrings.settings_sign_out.localized() static let signOutAlertTitle = sharedStrings.settings_sign_out_dialog_title.localized() static let signOutAlertMessage = sharedStrings.settings_sign_out_dialog_explanation.localized() @@ -363,6 +378,11 @@ enum Strings { static let privacyPolicyURL = sharedStrings.settings_privacy_policy_url.localized() static let reportProblemURL = sharedStrings.settings_report_problem_url.localized() + static let rateInAppStore = sharedStrings.settings_rate_in_app_store.localized() + static let rateInAppStoreURL = sharedStrings.settings_rate_in_app_store_url.localized() + + static let subscription = sharedStrings.settings_subscription.localized() + enum Theme { static let title = sharedStrings.settings_theme.localized() diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/AppAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/AppAssembly.swift index 1f923214b6..abd7daa8a3 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/AppAssembly.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/AppAssembly.swift @@ -17,8 +17,11 @@ final class AppAssembly: UIKitAssembly { feature: feature ) - let viewController = AppViewController(viewModel: viewModel) + let router = AppRouter() + + let viewController = AppViewController(viewModel: viewModel, router: router) viewModel.viewController = viewController + router.viewController = viewController return viewController } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/AppViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/AppViewModel.swift index 34b83a365e..e70d3d9895 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/AppViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/AppViewModel.swift @@ -135,6 +135,18 @@ extension AppViewModel: NotificationsOnboardingOutputProtocol { } } +// MARK: - AppViewModel: UsersQuestionnaireOnboardingOutputProtocol - + +extension AppViewModel: UsersQuestionnaireOnboardingOutputProtocol { + func handleUsersQuestionnaireOnboardingCompleted() { + onNewMessage( + AppFeatureMessageWelcomeOnboardingMessage( + message: WelcomeOnboardingFeatureMessageUsersQuestionnaireOnboardingCompleted() + ) + ) + } +} + // MARK: - AppViewModel: FirstProblemOnboardingOutputProtocol - extension AppViewModel: FirstProblemOnboardingOutputProtocol { @@ -200,6 +212,13 @@ private extension AppViewModel { name: .attAuthorizationStatusDidChange, object: nil ) + + notificationCenter.addObserver( + self, + selector: #selector(handleApplicationWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: UIApplication.shared + ) } @objc @@ -260,6 +279,12 @@ AppViewModel: \(#function) PushNotificationData not found in userInfo = \(String let isAuthorized = authorizationStatus == .authorized analytic.setAppTrackingTransparencyAuthorizationStatus(isAuthorized: isAuthorized) } + + @objc + private func handleApplicationWillEnterForeground() { + #warning("Enable when subscription purchase is implemented") + //onNewMessage(AppFeatureMessageAppBecomesActive()) + } } // MARK: - AppViewModel: StreakRecoveryModalDelegate - diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/ViewControllers/AppRouter.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/ViewControllers/AppRouter.swift new file mode 100644 index 0000000000..dd3ef6c8ed --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/ViewControllers/AppRouter.swift @@ -0,0 +1,155 @@ +import shared +import SwiftUI +import UIKit + +final class AppRouter { + weak var viewController: UIViewController? + + func route(_ route: Route) { + guard let viewController else { + return assertionFailure("AppRouter: viewController is nil") + } + + let viewControllerToPresent: UIViewController = { + switch route { + case .auth(let isInSignUpMode, let moduleOutput): + let assembly = AuthSocialAssembly( + isInSignUpMode: isInSignUpMode, + output: moduleOutput + ) + return UIHostingController(rootView: assembly.makeModule()) + case .studyPlan(let appTabBarControllerDelegate): + return AppTabBarController( + initialTab: .studyPlan, + availableTabs: AppTabItemsAvailabilityService.shared.getAvailableTabs(), + appTabBarControllerDelegate: appTabBarControllerDelegate + ) + case .studyPlanWithStep(let appTabBarControllerDelegate, let stepRoute): + let tabBarController = AppTabBarController( + initialTab: .studyPlan, + availableTabs: AppTabItemsAvailabilityService.shared.getAvailableTabs(), + appTabBarControllerDelegate: appTabBarControllerDelegate + ) + + if !tabBarController.isViewLoaded { + _ = tabBarController.view + } + + DispatchQueue.main.async { + let index = tabBarController.selectedIndex + + guard + let navigationController = tabBarController.children[index] as? UINavigationController + else { + return assertionFailure("AppRouter: Expected UINavigationController") + } + + let stepAssembly = StepAssembly(stepRoute: stepRoute) + navigationController.pushViewController(stepAssembly.makeModule(), animated: false) + } + + return tabBarController + case .trackSelection: + let assembly = TrackSelectionListAssembly(isNewUserMode: true) + let navigationController = UINavigationController( + rootViewController: assembly.makeModule() + ) + navigationController.navigationBar.prefersLargeTitles = true + return navigationController + case .onboarding(let moduleOutput): + let assembly = WelcomeAssembly(output: moduleOutput) + return UIHostingController(rootView: assembly.makeModule()) + case .firstProblemOnboarding(let isNewUserMode, let moduleOutput): + let assembly = FirstProblemOnboardingAssembly( + isNewUserMode: isNewUserMode, + output: moduleOutput + ) + return assembly.makeModule() + case .notificationOnboarding(let moduleOutput): + let assembly = NotificationsOnboardingAssembly(output: moduleOutput) + return assembly.makeModule() + case .usersQuestionnaireOnboarding(let moduleOutput): + let assembly = UsersQuestionnaireOnboardingAssembly(moduleOutput: moduleOutput) + return assembly.makeModule() + } + }() + + let fromViewController = viewController.children.first { childrenViewController in + if childrenViewController is UIHostingController { + return false + } + return true + } + if let fromViewController, + type(of: fromViewController) == type(of: viewControllerToPresent) { + return + } + + assert(viewController.children.count <= 2) + + swapRootViewController( + for: viewController, + from: fromViewController, + to: viewControllerToPresent + ) + } + + // MARK: Private API + + private func swapRootViewController( + for viewController: UIViewController, + from oldViewController: UIViewController?, + to newViewController: UIViewController + ) { + oldViewController?.willMove(toParent: nil) + + viewController.addChild(newViewController) + + viewController.view.addSubview(newViewController.view) + newViewController.view.translatesAutoresizingMaskIntoConstraints = false + newViewController.view.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + newViewController.view.alpha = 0 + newViewController.view.setNeedsLayout() + newViewController.view.layoutIfNeeded() + + UIView.animate( + withDuration: Animation.swapRootViewControllerAnimationDuration, + delay: 0, + options: .transitionFlipFromLeft, + animations: { + newViewController.view.alpha = 1 + oldViewController?.view.alpha = 0 + }, + completion: { isFinished in + guard isFinished else { + return + } + + oldViewController?.view.removeFromSuperview() + oldViewController?.removeFromParent() + + newViewController.didMove(toParent: viewController) + } + ) + } + + // MARK: Inner Types + + enum Route { + case auth(isInSignUpMode: Bool, moduleOutput: AuthOutputProtocol?) + case studyPlan(appTabBarControllerDelegate: AppTabBarControllerDelegate?) + case studyPlanWithStep(appTabBarControllerDelegate: AppTabBarControllerDelegate?, stepRoute: StepRoute) + case trackSelection + case onboarding(moduleOutput: WelcomeOutputProtocol?) + case firstProblemOnboarding(isNewUserMode: Bool, moduleOutput: FirstProblemOnboardingOutputProtocol?) + case notificationOnboarding(moduleOutput: NotificationsOnboardingOutputProtocol?) + case usersQuestionnaireOnboarding(moduleOutput: UsersQuestionnaireOnboardingOutputProtocol?) + } + + enum Animation { + static let swapRootViewControllerAnimationDuration: TimeInterval = 0.3 + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/ViewControllers/AppViewController.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/ViewControllers/AppViewController.swift index f73a5c5f30..e5b7e2bdd8 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/ViewControllers/AppViewController.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/App/ViewControllers/AppViewController.swift @@ -10,8 +10,6 @@ protocol AppViewControllerProtocol: AnyObject { extension AppViewController { enum Animation { - static let swapRootViewControllerAnimationDuration: TimeInterval = 0.3 - fileprivate static let clickedNotificationViewActionNavigateToHomeCompletionDelay: TimeInterval = 0.33 fileprivate static let clickedNotificationViewActionDismissProgressHUDDelay: TimeInterval = 0.33 } @@ -19,11 +17,13 @@ extension AppViewController { final class AppViewController: UIViewController { private let viewModel: AppViewModel + private let router: AppRouter var appView: AppView? { view as? AppView } - init(viewModel: AppViewModel) { + init(viewModel: AppViewModel, router: AppRouter) { self.viewModel = viewModel + self.router = router super.init(nibName: nil, bundle: nil) } @@ -102,44 +102,22 @@ extension AppViewController: AppViewControllerProtocol { } private func handleNavigateToViewAction(_ viewAction: AppFeatureActionViewActionNavigateToKs) { - #warning("TODO: Code dublication, see handleWelcomeOnboardingViewAction(_:)") - let viewControllerToPresent: UIViewController = { - switch viewAction { - case .onboardingScreen: - return UIHostingController(rootView: WelcomeAssembly(output: viewModel).makeModule()) - case .studyPlan: - return AppTabBarController( - initialTab: .studyPlan, - availableTabs: AppTabItemsAvailabilityService.shared.getAvailableTabs(), - appTabBarControllerDelegate: viewModel - ) - case .authScreen(let data): - let assembly = AuthSocialAssembly(isInSignUpMode: data.isInSignUpMode, output: viewModel) - return UIHostingController(rootView: assembly.makeModule()) - case .trackSelectionScreen: - let assembly = TrackSelectionListAssembly(isNewUserMode: true) - let navigationController = UINavigationController( - rootViewController: assembly.makeModule() - ) - navigationController.navigationBar.prefersLargeTitles = true - return navigationController - } - }() - - let fromViewController = children.first { viewController in - if viewController is UIHostingController { - return false - } - return true - } - if let fromViewController, - type(of: fromViewController) == type(of: viewControllerToPresent) { - return + switch viewAction { + case .authScreen(let data): + router.route(.auth(isInSignUpMode: data.isInSignUpMode, moduleOutput: viewModel)) + case .welcomeScreen: + router.route(.onboarding(moduleOutput: viewModel)) + case .studyPlan: + router.route(.studyPlan(appTabBarControllerDelegate: viewModel)) + case .trackSelectionScreen: + router.route(.trackSelection) + case .paywall: + #warning("TODO: ALTAPPS-1116; implement paywall Route") + router.route(.studyPlan(appTabBarControllerDelegate: viewModel)) + case .studyPlanWithPaywall: + #warning("TODO: ALTAPPS-1116; implent studyPlanWithPaywall Route") + router.route(.studyPlan(appTabBarControllerDelegate: viewModel)) } - - assert(children.count <= 2) - - swapRootViewController(from: fromViewController, to: viewControllerToPresent) } private func handleStreakRecoveryViewAction(_ viewAction: StreakRecoveryFeatureActionViewActionKs) { @@ -239,102 +217,22 @@ extension AppViewController: AppViewControllerProtocol { private func handleWelcomeOnboardingViewAction( _ viewAction: WelcomeOnboardingFeatureActionViewActionKs ) { - #warning("TODO: Code dublication, see handleNavigateToViewAction(_:)") - let viewControllerToPresent: UIViewController = { - switch viewAction { - case .navigateTo(let navigateToViewAction): - switch WelcomeOnboardingFeatureActionViewActionNavigateToKs(navigateToViewAction) { - case .firstProblemOnboardingScreen(let data): - let assembly = FirstProblemOnboardingAssembly( - isNewUserMode: data.isNewUserMode, - output: viewModel - ) - return assembly.makeModule() - case .notificationOnboardingScreen: - let assembly = NotificationsOnboardingAssembly(output: viewModel) - return assembly.makeModule() - case .studyPlanWithStep(let navigateToStudyPlanWithStepViewAction): - let tabBarController = AppTabBarController( - initialTab: .studyPlan, - availableTabs: AppTabItemsAvailabilityService.shared.getAvailableTabs(), - appTabBarControllerDelegate: viewModel - ) - - if !tabBarController.isViewLoaded { - _ = tabBarController.view - } - - DispatchQueue.main.async { - let index = tabBarController.selectedIndex - - guard - let navigationController = tabBarController.children[index] as? UINavigationController - else { - return assertionFailure("Expected UINavigationController") - } - - let stepAssembly = StepAssembly(stepRoute: navigateToStudyPlanWithStepViewAction.stepRoute) - navigationController.pushViewController(stepAssembly.makeModule(), animated: false) - } - - return tabBarController - } - } - }() - - let fromViewController = children.first { viewController in - if viewController is UIHostingController { - return false + switch viewAction { + case .navigateTo(let navigateToViewAction): + switch WelcomeOnboardingFeatureActionViewActionNavigateToKs(navigateToViewAction) { + case .firstProblemOnboardingScreen(let data): + router.route(.firstProblemOnboarding(isNewUserMode: data.isNewUserMode, moduleOutput: viewModel)) + case .notificationOnboardingScreen: + router.route(.notificationOnboarding(moduleOutput: viewModel)) + case .studyPlanWithStep(let data): + router.route(.studyPlanWithStep(appTabBarControllerDelegate: viewModel, stepRoute: data.stepRoute)) + case .usersQuestionnaireOnboardingScreen: + router.route(.usersQuestionnaireOnboarding(moduleOutput: viewModel)) + case .paywall: + #warning("TODO: ALTAPPS-1116") + router.route(.studyPlan(appTabBarControllerDelegate: viewModel)) } - return true - } - if let fromViewController, - type(of: fromViewController) == type(of: viewControllerToPresent) { - return } - - assert(children.count <= 2) - - swapRootViewController(from: fromViewController, to: viewControllerToPresent) - } - - private func swapRootViewController( - from oldViewController: UIViewController?, - to newViewController: UIViewController - ) { - oldViewController?.willMove(toParent: nil) - - addChild(newViewController) - - view.addSubview(newViewController.view) - newViewController.view.translatesAutoresizingMaskIntoConstraints = false - newViewController.view.snp.makeConstraints { make in - make.edges.equalToSuperview() - } - - newViewController.view.alpha = 0 - newViewController.view.setNeedsLayout() - newViewController.view.layoutIfNeeded() - - UIView.animate( - withDuration: Animation.swapRootViewControllerAnimationDuration, - delay: 0, - options: .transitionFlipFromLeft, - animations: { - newViewController.view.alpha = 1 - oldViewController?.view.alpha = 0 - }, - completion: { isFinished in - guard isFinished else { - return - } - - oldViewController?.view.removeFromSuperview() - oldViewController?.removeFromParent() - - newViewController.didMove(toParent: self) - } - ) } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/AuthCredentials/Views/AuthCredentialsFormView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/AuthCredentials/Views/AuthCredentialsFormView.swift index fa5b00a223..53b1d53fba 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/AuthCredentials/Views/AuthCredentialsFormView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/AuthCredentials/Views/AuthCredentialsFormView.swift @@ -61,7 +61,7 @@ struct AuthCredentialsFormView: View { Divider() } - if let errorMessage = errorMessage { + if let errorMessage { AuthCredentialsErrorView(message: errorMessage) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/AuthCredentials/Views/AuthTextField.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/AuthCredentials/Views/AuthTextField.swift index 43d9a96a45..37475a10a5 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/AuthCredentials/Views/AuthTextField.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/AuthCredentials/Views/AuthTextField.swift @@ -17,14 +17,14 @@ final class AuthTextField: UITextField { button.setImage(Appearance.imageEyeOpened, for: .normal) button.imageView?.contentMode = .scaleAspectFit button.tintColor = Appearance.tintColor - button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + button.imageEdgeInsets = .zero button.frame = CGRect( - x: self.frame.size.width - Appearance.eyeButtonSize.width, - y: (self.frame.size.height - Appearance.eyeButtonSize.height) / 2, + x: frame.size.width - Appearance.eyeButtonSize.width, + y: (frame.size.height - Appearance.eyeButtonSize.height) / 2, width: Appearance.eyeButtonSize.width, height: Appearance.eyeButtonSize.height ) - button.addTarget(self, action: #selector(self.togglePasswordField), for: .touchUpInside) + button.addTarget(self, action: #selector(togglePasswordField), for: .touchUpInside) return button }() diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/AuthSocial/Views/AuthSocialControlsView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/AuthSocial/Views/AuthSocialControlsView.swift index 87a6352684..e1f76e349c 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/AuthSocial/Views/AuthSocialControlsView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/AuthSocial/Views/AuthSocialControlsView.swift @@ -15,7 +15,7 @@ struct AuthSocialControlsView: View { text: provider.humanReadableName, imageName: provider.imageName, action: { - self.onSocialAuthProviderClick(provider) + onSocialAuthProviderClick(provider) } ) } @@ -23,7 +23,7 @@ struct AuthSocialControlsView: View { if isContinueWithEmailAvailable { Button( Strings.Auth.Social.emailText, - action: self.onContinueWithEmailClick + action: onContinueWithEmailClick ) .padding(.top) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeAssembly.swift index 4b51e29dc4..195f0d7512 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeAssembly.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeAssembly.swift @@ -9,7 +9,7 @@ final class HomeAssembly: UIKitAssembly { feature: homeComponent.homeFeature ) - let stackRouter = SwiftUIStackRouter() + let stackRouter = StackRouter() let panModalPresenter = PanModalPresenter() let homeView = HomeView( diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeViewModel.swift index ee106cc42c..65f6c8217f 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeViewModel.swift @@ -2,7 +2,6 @@ import shared import UIKit final class HomeViewModel: FeatureViewModel { - private var applicationWasInBackground = false private var shouldReloadContent = false var homeStateKs: HomeFeatureHomeStateKs { .init(state.homeState) } @@ -20,14 +19,8 @@ final class HomeViewModel: FeatureViewModel Void)? + func sendFeedback(feedbackEmailData: FeedbackEmailData, presentationController: UIViewController) { self.presentationController = presentationController @@ -67,5 +69,7 @@ extension SendEmailFeedbackController: MFMailComposeViewControllerDelegate { } ) } + + onDidFinish?() } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/ProfileSettingsViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/ProfileSettingsViewModel.swift index c18de6eb9b..f59857cfcd 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/ProfileSettingsViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/ProfileSettingsViewModel.swift @@ -2,13 +2,13 @@ import Foundation import shared final class ProfileSettingsViewModel: FeatureViewModel< - ProfileSettingsFeatureState, + ProfileSettingsFeatureViewState, ProfileSettingsFeatureMessage, ProfileSettingsFeatureActionViewAction > { private static let applyNewThemeAnimationDelay: TimeInterval = 0.33 private static let dismissScreenAnimationDelay = - AppViewController.Animation.swapRootViewControllerAnimationDuration * 0.75 + AppRouter.Animation.swapRootViewControllerAnimationDuration * 0.75 private let applicationThemeService: ApplicationThemeServiceProtocol @@ -17,7 +17,7 @@ final class ProfileSettingsViewModel: FeatureViewModel< // It's impossible to handle onTap on `Picker`, so using `onAppear` callback with debouncer. private let analyticLogClickedThemeEventDebouncer: DebouncerProtocol = Debouncer() - var stateKs: ProfileSettingsFeatureStateKs { .init(state) } + var stateKs: ProfileSettingsFeatureViewStateKs { .init(state) } init( applicationThemeService: ApplicationThemeServiceProtocol, @@ -26,18 +26,14 @@ final class ProfileSettingsViewModel: FeatureViewModel< self.applicationThemeService = applicationThemeService super.init(feature: feature) - onNewMessage(ProfileSettingsFeatureMessageInitMessage(forceUpdate: false)) + onNewMessage(ProfileSettingsFeatureMessageInitMessage()) } override func shouldNotifyStateDidChange( - oldState: ProfileSettingsFeatureState, - newState: ProfileSettingsFeatureState + oldState: ProfileSettingsFeatureViewState, + newState: ProfileSettingsFeatureViewState ) -> Bool { - ProfileSettingsFeatureStateKs(oldState) != ProfileSettingsFeatureStateKs(newState) - } - - func doRetryLoadProfileSettings() { - onNewMessage(ProfileSettingsFeatureMessageInitMessage(forceUpdate: true)) + ProfileSettingsFeatureViewStateKs(oldState) != ProfileSettingsFeatureViewStateKs(newState) } func doThemeChange(newTheme: ApplicationTheme) { @@ -76,6 +72,23 @@ final class ProfileSettingsViewModel: FeatureViewModel< onNewMessage(ProfileSettingsFeatureMessageDeleteAccountNoticeHidden(isConfirmed: isConfirmed)) } + func doRateInAppStorePresentation() { + onNewMessage(ProfileSettingsFeatureMessageClickedRateUsInAppStoreEventMessage()) + + guard let url = URL(string: Strings.Settings.rateInAppStoreURL) else { + return assertionFailure("Invalid URL") + } + + UIApplication.shared.open(url, options: [:]) { success in + if !success { + WebControllerManager.shared.presentWebControllerWithURL( + url, + controllerType: .safari + ) + } + } + } + // MARK: Analytic func logViewedEvent() { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/Views/ProfileSettingsSubscriptionSectionView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/Views/ProfileSettingsSubscriptionSectionView.swift new file mode 100644 index 0000000000..7da4f2183a --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/Views/ProfileSettingsSubscriptionSectionView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct ProfileSettingsSubscriptionSectionView: View { + let description: String + + let action: () -> Void + + var body: some View { + Section(header: Text(Strings.Settings.subscription)) { + Button( + action: { action() }, + label: { + HStack(spacing: 0) { + Text(description) + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) + Spacer() + NavigationLink.empty + } + } + ) + .accentColor(.primaryText) + } + } +} + +#Preview { + NavigationView { + Form { + ProfileSettingsSubscriptionSectionView( + description: "Try Mobile only plan for $12.00", + action: {} + ) + + ProfileSettingsSubscriptionSectionView( + description: "Mobile only", + action: {} + ) + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/ProfileSettingsView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/Views/ProfileSettingsView.swift similarity index 88% rename from iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/ProfileSettingsView.swift rename to iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/Views/ProfileSettingsView.swift index af79f84096..b380734da5 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/ProfileSettingsView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProfileSettings/Views/ProfileSettingsView.swift @@ -49,20 +49,33 @@ struct ProfileSettingsView: View { switch viewModel.stateKs { case .idle, .loading: ProgressView() - case .error: - PlaceholderView(configuration: .networkError(action: viewModel.doRetryLoadProfileSettings)) case .content(let content): if content.isLoadingMagicLink { let _ = ProgressHUD.show() } - buildContent(profileSettings: content.profileSettings) + buildContent( + profileSettings: content.profileSettings, + subscriptionState: content.subscriptionState + ) } } @ViewBuilder - private func buildContent(profileSettings: ProfileSettings) -> some View { + private func buildContent( + profileSettings: ProfileSettings, + subscriptionState: ProfileSettingsFeatureViewStateContent.SubscriptionState? + ) -> some View { Form { + if let subscriptionState { + ProfileSettingsSubscriptionSectionView( + description: subscriptionState.description_, + action: { + #warning("TODO: ALTAPPS-1138") + } + ) + } + Section(header: Text(Strings.Settings.appearance)) { Picker( Strings.Settings.Theme.title, @@ -111,11 +124,6 @@ struct ProfileSettingsView: View { .foregroundColor(.secondaryText) } } - - // ALTAPPS-312 - //Button(Strings.Settings.rateApplication) { - //} - //.foregroundColor(Color(ColorPalette.primary)) } Section { @@ -129,6 +137,9 @@ struct ProfileSettingsView: View { onTap: viewModel.logClickedReportProblemEvent ) .foregroundColor(.primaryText) + + Button(Strings.Settings.rateInAppStore, action: viewModel.doRateInAppStorePresentation) + .foregroundColor(.primaryText) } Section { @@ -209,13 +220,16 @@ struct ProfileSettingsView: View { switch ProfileSettingsFeatureActionViewActionNavigateToKs(navigateToViewAction) { case .parentScreen: presentationMode.wrappedValue.dismiss() + case .paywall: + #warning("TODO: ALTAPPS-1126") + case .subscriptionManagement: + #warning("TODO: ALTAPPS-1132") } } } } -struct ProfileSettingsView_Previews: PreviewProvider { - static var previews: some View { - ProfileSettingsAssembly().makeModule() - } +@available(iOS 15.0, *) +#Preview { + ProfileSettingsAssembly().makeModule() } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProjectSelection/Details/Views/Content/ProjectSelectionDetailsContentView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProjectSelection/Details/Views/Content/ProjectSelectionDetailsContentView.swift index de9055e9ba..408358d4e7 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProjectSelection/Details/Views/Content/ProjectSelectionDetailsContentView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProjectSelection/Details/Views/Content/ProjectSelectionDetailsContentView.swift @@ -3,18 +3,6 @@ import SwiftUI extension ProjectSelectionDetailsContentView { struct Appearance { let spacing = LayoutInsets.defaultInset - - let callToActionBlurInsets = LayoutInsets( - horizontal: LayoutInsets.smallInset, - vertical: -LayoutInsets.smallInset - ) - - func makeCallToActionButtonStyle(isEnabled: Bool) -> RoundedRectangleButtonStyle { - var style = RoundedRectangleButtonStyle(style: .violet) - style.backgroundDisabledOpacity = 1 - style.foregroundColor = isEnabled ? Color(ColorPalette.onPrimary) : Color(ColorPalette.onPrimaryAlpha60) - return style - } } } @@ -42,13 +30,7 @@ struct ProjectSelectionDetailsContentView: View { let isCallToActionButtonEnabled: Bool let onCallToActionButtonTap: () -> Void - private let callToActionButtonFeedbackGenerator = FeedbackGenerator(feedbackType: .selection) - var body: some View { - let callToActionButtonStyle = appearance.makeCallToActionButtonStyle( - isEnabled: isCallToActionButtonEnabled - ) - ScrollView { VStack(spacing: appearance.spacing) { ProjectSelectionDetailsLearningOutcomesView( @@ -72,36 +54,32 @@ struct ProjectSelectionDetailsContentView: View { ProjectSelectionDetailsProviderView(title: providerName) } .padding() - .padding(.bottom, callToActionButtonStyle.minHeight) } .frame(maxWidth: .infinity) .navigationTitle(navigationTitle) - .overlay( - buildCallToActionButton(buttonStyle: callToActionButtonStyle), - alignment: .bottom - ) + .safeAreaInsetBottomCompatibility(footerView) } - @MainActor - @ViewBuilder - private func buildCallToActionButton( - buttonStyle: RoundedRectangleButtonStyle - ) -> some View { - Button( - Strings.ProjectSelectionDetails.callToActionButtonTitle, - action: { - callToActionButtonFeedbackGenerator.triggerFeedback() - onCallToActionButtonTap() - } - ) - .buttonStyle(buttonStyle) - .padding(.horizontal) - .background( - Color(ColorPalette.surface) - .padding(appearance.callToActionBlurInsets.edgeInsets) - .blur(radius: buttonStyle.cornerRadius) - ) - .disabled(!isCallToActionButtonEnabled) + @MainActor @ViewBuilder private var footerView: some View { + if isCallToActionButtonEnabled { + Button( + Strings.ProjectSelectionDetails.callToActionButtonTitle, + action: { + FeedbackGenerator(feedbackType: .selection).triggerFeedback() + onCallToActionButtonTap() + } + ) + .buttonStyle(.primary) + .shineEffect() + .padding() + .background( + TransparentBlurView() + .edgesIgnoringSafeArea(.all) + ) + .fixedSize(horizontal: false, vertical: true) + } else { + EmptyView() + } } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProjectSelection/Details/Views/Skeleton/ProjectSelectionDetailsSkeletonView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProjectSelection/Details/Views/Skeleton/ProjectSelectionDetailsSkeletonView.swift index ee40b8dfd4..b365d69c11 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProjectSelection/Details/Views/Skeleton/ProjectSelectionDetailsSkeletonView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/ProjectSelection/Details/Views/Skeleton/ProjectSelectionDetailsSkeletonView.swift @@ -16,7 +16,7 @@ struct ProjectSelectionDetailsSkeletonView: View { .frame(height: 78) SkeletonRoundedView() - .frame(height: 44) + .frame(height: RoundedRectangleButtonStyle.primary.minHeight) } .padding() } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/RequestReview/RequestReviewModalAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/RequestReview/RequestReviewModalAssembly.swift new file mode 100644 index 0000000000..4c37d52ee4 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/RequestReview/RequestReviewModalAssembly.swift @@ -0,0 +1,27 @@ +import shared +import UIKit + +final class RequestReviewModalAssembly: UIKitAssembly { + private let stepRoute: StepRoute + + init(stepRoute: StepRoute) { + self.stepRoute = stepRoute + } + + func makeModule() -> UIViewController { + let requestReviewModalComponent = AppGraphBridge.sharedAppGraph.buildRequestReviewModalComponent( + stepRoute: stepRoute + ) + + let requestReviewModalViewModel = RequestReviewModalViewModel( + feature: requestReviewModalComponent.requestReviewModalFeature + ) + + let requestReviewModalViewController = RequestReviewModalViewController( + viewModel: requestReviewModalViewModel + ) + requestReviewModalViewModel.viewController = requestReviewModalViewController + + return requestReviewModalViewController + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/RequestReview/RequestReviewModalView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/RequestReview/RequestReviewModalView.swift new file mode 100644 index 0000000000..e9bb2df83d --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/RequestReview/RequestReviewModalView.swift @@ -0,0 +1,226 @@ +import shared +import SnapKit +import UIKit + +extension RequestReviewModalView { + struct Appearance { + let contentStackViewSpacing: CGFloat = LayoutInsets.defaultInset * 2 + let contentStackViewInsets = LayoutInsets.default + + let textContainerStackViewSpacing: CGFloat = LayoutInsets.defaultInset + let buttonsContainerStackViewSpacing: CGFloat = LayoutInsets.defaultInset + + let titleLabelTextFont = UIFont.preferredFont(for: .largeTitle, weight: .bold) + let titleLabelTextColor = UIColor.newPrimaryText + + let descriptionLabelTextFont = UIFont.preferredFont(forTextStyle: .headline) + let descriptionLabelTextColor = UIColor.newPrimaryText + + let positiveButtonButtonHeight: CGFloat = 44 + let negativeButtonButtonHeight: CGFloat = 44 + + let backgroundColor = UIColor.systemBackground + } +} + +final class RequestReviewModalView: UIView { + let appearance: Appearance + + private lazy var contentStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = appearance.contentStackViewSpacing + stackView.alignment = .leading + stackView.distribution = .fill + return stackView + }() + + private lazy var textContainerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = appearance.textContainerStackViewSpacing + stackView.alignment = .leading + stackView.distribution = .fill + return stackView + }() + + private lazy var buttonsContainerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = appearance.buttonsContainerStackViewSpacing + stackView.alignment = .fill + stackView.distribution = .fill + return stackView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = appearance.titleLabelTextFont + label.textColor = appearance.titleLabelTextColor + label.lineBreakMode = .byWordWrapping + label.numberOfLines = 0 + return label + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.font = appearance.descriptionLabelTextFont + label.textColor = appearance.descriptionLabelTextColor + label.lineBreakMode = .byWordWrapping + label.numberOfLines = 0 + label.isHidden = true + return label + }() + + private lazy var positiveButton: UIKitRoundedRectangleButton = { + let button = UIKitRoundedRectangleButton() + button.addTarget(self, action: #selector(positiveButtonTapped), for: .touchUpInside) + return button + }() + + private lazy var negativeButton: UIKitRoundedRectangleButton = { + let button = UIKitRoundedRectangleButton(style: .outline) + button.addTarget(self, action: #selector(negativeButtonTapped), for: .touchUpInside) + return button + }() + + var onPositiveButtonTap: (() -> Void)? + var onNegativeButtonTap: (() -> Void)? + + override var intrinsicContentSize: CGSize { + let contentStackViewSize = contentStackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + + let height = appearance.contentStackViewInsets.top + + contentStackViewSize.height + + appearance.contentStackViewInsets.bottom + + return CGSize(width: UIView.noIntrinsicMetric, height: height) + } + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance() + ) { + self.appearance = appearance + super.init(frame: frame) + + self.setupView() + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func renderState(_ state: RequestReviewModalFeature.ViewState) { + titleLabel.text = state.title + + descriptionLabel.text = state.description_ + descriptionLabel.isHidden = state.description_?.isEmpty ?? true + + positiveButton.setTitle(state.positiveButtonText, for: .normal) + negativeButton.setTitle(state.negativeButtonText, for: .normal) + + if state.state == .negative { + positiveButton.style = .violet + + buttonsContainerStackView.axis = .vertical + buttonsContainerStackView.distribution = .fill + } else { + positiveButton.style = .outline + + buttonsContainerStackView.axis = .horizontal + buttonsContainerStackView.distribution = .fillEqually + } + + layoutIfNeeded() + invalidateIntrinsicContentSize() + } + + @objc + private func positiveButtonTapped() { + onPositiveButtonTap?() + } + + @objc + private func negativeButtonTapped() { + onNegativeButtonTap?() + } +} + +extension RequestReviewModalView: ProgrammaticallyInitializableViewProtocol { + func setupView() { + backgroundColor = appearance.backgroundColor + } + + func addSubviews() { + addSubview(contentStackView) + + contentStackView.addArrangedSubview(textContainerStackView) + textContainerStackView.addArrangedSubview(titleLabel) + textContainerStackView.addArrangedSubview(descriptionLabel) + + contentStackView.addArrangedSubview(buttonsContainerStackView) + buttonsContainerStackView.addArrangedSubview(positiveButton) + buttonsContainerStackView.addArrangedSubview(negativeButton) + } + + func makeConstraints() { + contentStackView.translatesAutoresizingMaskIntoConstraints = false + contentStackView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(appearance.contentStackViewInsets.top) + make.leading.equalTo(safeAreaLayoutGuide).offset(appearance.contentStackViewInsets.leading) + make.bottom.lessThanOrEqualToSuperview().offset(-appearance.contentStackViewInsets.bottom) + make.trailing.equalTo(safeAreaLayoutGuide).offset(-appearance.contentStackViewInsets.trailing) + } + + buttonsContainerStackView.translatesAutoresizingMaskIntoConstraints = false + buttonsContainerStackView.snp.makeConstraints { make in + make.width.equalToSuperview() + } + + positiveButton.translatesAutoresizingMaskIntoConstraints = false + positiveButton.snp.makeConstraints { make in + make.height.equalTo(appearance.positiveButtonButtonHeight) + } + + negativeButton.translatesAutoresizingMaskIntoConstraints = false + negativeButton.snp.makeConstraints { make in + make.height.equalTo(appearance.negativeButtonButtonHeight) + } + } +} + +#if DEBUG +@available(iOS 17.0, *) +#Preview { + let view = RequestReviewModalView() + view.renderState( + RequestReviewModalFeature.ViewState( + title: "Do you enjoy\nHyperskill app?", + description: nil, + positiveButtonText: "Yes", + negativeButtonText: "No", + state: RequestReviewModalFeature.ViewStateState.awaiting + ) + ) + return view +} + +@available(iOS 17.0, *) +#Preview { + let view = RequestReviewModalView() + view.renderState( + RequestReviewModalFeature.ViewState( + title: "Thank you!", + description: "Share what you disliked to help us improve your experience.", + positiveButtonText: "Write a request", + negativeButtonText: "Maybe later", + state: RequestReviewModalFeature.ViewStateState.negative + ) + ) + return view +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/RequestReview/RequestReviewModalViewController.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/RequestReview/RequestReviewModalViewController.swift new file mode 100644 index 0000000000..35b6a422fc --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/RequestReview/RequestReviewModalViewController.swift @@ -0,0 +1,93 @@ +import PanModal +import shared +import StoreKit +import UIKit + +protocol RequestReviewModalViewControllerProtocol: AnyObject { + func displayState(_ state: RequestReviewModalFeature.ViewState) + func displayViewAction(_ viewAction: RequestReviewModalFeatureActionViewActionKs) +} + +final class RequestReviewModalViewController: PanModalPresentableViewController { + private let viewModel: RequestReviewModalViewModel + + var requestReviewModalView: RequestReviewModalView? { view as? RequestReviewModalView } + + override var shortFormHeight: PanModalHeight { .contentHeight(view.intrinsicContentSize.height) } + + override var longFormHeight: PanModalHeight { shortFormHeight } + + init(viewModel: RequestReviewModalViewModel) { + self.viewModel = viewModel + super.init() + } + + override func loadView() { + let view = RequestReviewModalView() + view.onPositiveButtonTap = { [weak self] in + FeedbackGenerator(feedbackType: .selection).triggerFeedback() + self?.viewModel.doPositiveButtonAction() + } + view.onNegativeButtonTap = { [weak self] in + FeedbackGenerator(feedbackType: .selection).triggerFeedback() + self?.viewModel.doNegativeButtonAction() + } + self.view = view + } + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.logShownEvent() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.startListening() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + viewModel.stopListening() + } + + override func panModalWillDismiss() { + viewModel.logHiddenEvent() + } +} + +extension RequestReviewModalViewController: RequestReviewModalViewControllerProtocol { + func displayState(_ state: RequestReviewModalFeature.ViewState) { + requestReviewModalView?.renderState(state) + + panModalSetNeedsLayoutUpdate() + panModalTransition(to: .shortForm) + } + + func displayViewAction(_ viewAction: RequestReviewModalFeatureActionViewActionKs) { + switch viewAction { + case .dismiss: + dismiss(animated: true) + case .requestUserReview: + dismiss( + animated: true, + completion: { + if let scene = UIApplication.shared.connectedScenes.first( + where: { $0.activationState == .foregroundActive } + ) as? UIWindowScene { + SKStoreReviewController.requestReview(in: scene) + } + } + ) + case .submitSupportRequest(let submitSupportRequestViewAction): + dismiss( + animated: true, + completion: { + WebControllerManager.shared.presentWebControllerWithURLString( + submitSupportRequestViewAction.url, + controllerType: .safari + ) + } + ) + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/RequestReview/RequestReviewModalViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/RequestReview/RequestReviewModalViewModel.swift new file mode 100644 index 0000000000..9e8df30a11 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/RequestReview/RequestReviewModalViewModel.swift @@ -0,0 +1,63 @@ +import Combine +import Foundation +import shared + +final class RequestReviewModalViewModel: FeatureViewModel< + RequestReviewModalFeature.ViewState, + RequestReviewModalFeatureMessage, + RequestReviewModalFeatureActionViewAction +> { + weak var viewController: RequestReviewModalViewController? + + private var isFirstStateDidChange = true + private var objectWillChangeSubscription: AnyCancellable? + + init(feature: Presentation_reduxFeature) { + super.init(feature: feature) + + self.objectWillChangeSubscription = objectWillChange.sink { [weak self] _ in + self?.mainScheduler.schedule { [weak self] in + if let strongSelf = self { + strongSelf.viewController?.displayState(strongSelf.state) + } + } + } + self.onViewAction = { [weak self] viewAction in + self?.mainScheduler.schedule { [weak self] in + if let strongSelf = self { + strongSelf.viewController?.displayViewAction( + RequestReviewModalFeatureActionViewActionKs(viewAction) + ) + } + } + } + } + + override func shouldNotifyStateDidChange( + oldState: RequestReviewModalFeature.ViewState, + newState: RequestReviewModalFeature.ViewState + ) -> Bool { + if isFirstStateDidChange { + isFirstStateDidChange = false + return true + } else { + return !oldState.isEqual(newState) + } + } + + func doPositiveButtonAction() { + onNewMessage(RequestReviewModalFeatureMessagePositiveButtonClicked()) + } + + func doNegativeButtonAction() { + onNewMessage(RequestReviewModalFeatureMessageNegativeButtonClicked()) + } + + func logShownEvent() { + onNewMessage(RequestReviewModalFeatureMessageShownEventMessage()) + } + + func logHiddenEvent() { + onNewMessage(RequestReviewModalFeatureMessageHiddenEventMessage()) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StageImplement/Modals/Unsupported/StageImplementUnsupportedModalView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StageImplement/Modals/Unsupported/StageImplementUnsupportedModalView.swift index 36bd2fd746..71d1d78969 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StageImplement/Modals/Unsupported/StageImplementUnsupportedModalView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StageImplement/Modals/Unsupported/StageImplementUnsupportedModalView.swift @@ -15,10 +15,7 @@ extension StageImplementUnsupportedModalView { let textContainerStackViewSpacing: CGFloat = LayoutInsets.defaultInset let titleLabelText = Strings.StageImplement.UnsupportedModal.title - let titleLabelTextFont = UIFont.preferredFont( - forTextStyle: .title2, - compatibleWith: .init(legibilityWeight: .bold) - ) + let titleLabelTextFont = UIFont.preferredFont(for: .title2, weight: .bold) let titleLabelTextColor = UIColor.primaryText let descriptionLabelText = Strings.StageImplement.UnsupportedModal.description diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/Modals/TopicCompletedModalViewController.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/Modals/TopicCompletedModalViewController.swift index 6660d56748..5e2a17d43d 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/Modals/TopicCompletedModalViewController.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/Modals/TopicCompletedModalViewController.swift @@ -123,7 +123,7 @@ final class TopicCompletedModalViewController: PanModalPresentableViewController let titleLabel = UILabel() titleLabel.text = Strings.Common.goodJob - titleLabel.font = .preferredFont(forTextStyle: .largeTitle, compatibleWith: .init(legibilityWeight: .bold)) + titleLabel.font = .preferredFont(for: .largeTitle, weight: .bold) titleLabel.textColor = .primaryText titleLabel.lineBreakMode = .byWordWrapping titleLabel.numberOfLines = 0 diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/StepView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/StepView.swift index cf64aa8136..47aed9a8d2 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/StepView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Step/Views/StepView.swift @@ -127,6 +127,8 @@ struct StepView: View { presentShareStreakSystemModal(streak: Int(showShareStreakSystemModalViewAction.streak)) case .showInterviewPreparationCompletedModal: presentInterviewPreparationFinishedModal() + case .showRequestUserReviewModal(let showRequestUserReviewModalViewAction): + presentRequestReviewModal(stepRoute: showRequestUserReviewModalViewAction.stepRoute) } } @@ -155,7 +157,7 @@ private extension StepView { } func presentDailyStepCompletedModal( - earnedGemsText: String, + earnedGemsText: String?, shareStreakData: StepCompletionFeatureShareStreakData ) { let modal = ProblemOfDaySolvedModalViewController( @@ -183,6 +185,11 @@ private extension StepView { let modal = InterviewPreparationCompletedModalViewController(delegate: viewModel) panModalPresenter.presentPanModal(modal) } + + func presentRequestReviewModal(stepRoute: StepRoute) { + let assembly = RequestReviewModalAssembly(stepRoute: stepRoute) + panModalPresenter.presentIfPanModal(assembly.makeModule()) + } } // MARK: - StepView_Previews: PreviewProvider - diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift index c94ec0c7e6..05ed1d78fd 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift @@ -2,6 +2,7 @@ import Foundation import shared import SwiftUI +// swiftlint:disable function_parameter_count protocol StepQuizChildQuizAssembly: Assembly { var moduleInput: StepQuizChildQuizInputProtocol? { get } var moduleOutput: StepQuizChildQuizOutputProtocol? { get set } @@ -123,3 +124,4 @@ enum StepQuizChildQuizViewFactory { } } } +// swiftlint:enable function_parameter_count diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift index 0d9ef37184..5de436d249 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/StepQuizViewModel.swift @@ -3,7 +3,7 @@ import shared import SwiftUI final class StepQuizViewModel: FeatureViewModel< - StepQuizFeatureState, + StepQuizFeature.State, StepQuizFeatureMessage, StepQuizFeatureActionViewAction > { @@ -42,7 +42,10 @@ final class StepQuizViewModel: FeatureViewModel< onNewMessage(StepQuizFeatureMessageInitWithStep(step: step, forceUpdate: false)) } - override func shouldNotifyStateDidChange(oldState: StepQuizFeatureState, newState: StepQuizFeatureState) -> Bool { + override func shouldNotifyStateDidChange( + oldState: StepQuizFeature.State, + newState: StepQuizFeature.State + ) -> Bool { if oldState.stepQuizState is StepQuizFeatureStepQuizStateAttemptLoading && newState.stepQuizState is StepQuizFeatureStepQuizStateAttemptLoaded { updateChildQuizSubscription = objectWillChange.sink { [weak self] in @@ -98,6 +101,14 @@ final class StepQuizViewModel: FeatureViewModel< moduleOutput?.stepQuizDidRequestContinue() } + func doUnsupportedQuizSolveOnTheWebAction() { + onNewMessage(StepQuizFeatureMessageUnsupportedQuizSolveOnTheWebClicked()) + } + + func doUnsupportedQuizGoToStudyPlanAction() { + onNewMessage(StepQuizFeatureMessageUnsupportedQuizGoToStudyPlanClicked()) + } + func makeViewData() -> StepQuizViewData { stepQuizViewDataMapper.mapStepDataToViewData(step: step, state: stepQuizStateKs) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/BottomControls/StepQuizBottomControls.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/BottomControls/StepQuizBottomControls.swift index 98e5365147..2b9fbd8444 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/BottomControls/StepQuizBottomControls.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/BottomControls/StepQuizBottomControls.swift @@ -7,7 +7,7 @@ struct StepQuizBottomControls: View { VStack { Divider() - StepQuizDiscussionsButton(onClick: self.onShowDiscussionsClick).padding() + StepQuizDiscussionsButton(onClick: onShowDiscussionsClick).padding() } .background(BackgroundView()) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/BottomControls/StepQuizDiscussionsButton.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/BottomControls/StepQuizDiscussionsButton.swift index e07ac88821..f15970c479 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/BottomControls/StepQuizDiscussionsButton.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/BottomControls/StepQuizDiscussionsButton.swift @@ -17,7 +17,7 @@ struct StepQuizDiscussionsButton: View { var body: some View { Button( - action: { self.onClick?() }, + action: { onClick?() }, label: { Text(Strings.StepQuiz.discussionsButton) .font(.body) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/Modals/ProblemOfDaySolvedModalViewController.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/Modals/ProblemOfDaySolvedModalViewController.swift index 401034c07a..1b19ceb890 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/Modals/ProblemOfDaySolvedModalViewController.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/Modals/ProblemOfDaySolvedModalViewController.swift @@ -40,7 +40,7 @@ final class ProblemOfDaySolvedModalViewController: PanModalPresentableViewContro private(set) var appearance = Appearance() - private let earnedGemsText: String + private let earnedGemsText: String? private let shareStreakData: StepCompletionFeatureShareStreakDataKs private lazy var contentStackView: UIStackView = { @@ -61,7 +61,7 @@ final class ProblemOfDaySolvedModalViewController: PanModalPresentableViewContro override var longFormHeight: PanModalHeight { shortFormHeight } init( - earnedGemsText: String, + earnedGemsText: String?, shareStreakData: StepCompletionFeatureShareStreakDataKs, delegate: ProblemOfDaySolvedModalViewControllerDelegate? ) { @@ -137,7 +137,7 @@ final class ProblemOfDaySolvedModalViewController: PanModalPresentableViewContro private func setupTitleView() { let label = UILabel() label.text = Strings.StepQuiz.ProblemOfDaySolvedModal.title - label.font = .preferredFont(forTextStyle: .largeTitle, compatibleWith: .init(legibilityWeight: .bold)) + label.font = .preferredFont(for: .largeTitle, weight: .bold) label.textColor = .primaryText label.lineBreakMode = .byWordWrapping label.numberOfLines = 0 @@ -183,6 +183,11 @@ final class ProblemOfDaySolvedModalViewController: PanModalPresentableViewContro return containerStackView } + if earnedGemsText == nil, + case .empty = shareStreakData { + return + } + let itemsStackView = UIStackView() itemsStackView.axis = .vertical itemsStackView.spacing = LayoutInsets.defaultInset @@ -191,12 +196,14 @@ final class ProblemOfDaySolvedModalViewController: PanModalPresentableViewContro contentStackView.addArrangedSubview(itemsStackView) - itemsStackView.addArrangedSubview( - makeItemView( - imageResource: .problemOfDaySolvedModalGemsBadge, - text: earnedGemsText + if let earnedGemsText { + itemsStackView.addArrangedSubview( + makeItemView( + imageResource: .problemOfDaySolvedModalGemsBadge, + text: earnedGemsText + ) ) - ) + } switch shareStreakData { case .content(let data): diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/Modals/ProblemsLimitReachedModalViewController.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/Modals/ProblemsLimitReachedModalViewController.swift index 93873148b0..adbf41f07c 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/Modals/ProblemsLimitReachedModalViewController.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/Modals/ProblemsLimitReachedModalViewController.swift @@ -31,7 +31,8 @@ extension ProblemsLimitReachedModalViewController { final class ProblemsLimitReachedModalViewController: PanModalPresentableViewController { private(set) var appearance = Appearance() - private let modalText: String + private let titleText: String + private let descriptionText: String private lazy var contentStackView: UIStackView = { let stackView = UIStackView() @@ -52,8 +53,13 @@ final class ProblemsLimitReachedModalViewController: PanModalPresentableViewCont private weak var delegate: ProblemsLimitReachedModalViewControllerDelegate? - init(modalText: String, delegate: ProblemsLimitReachedModalViewControllerDelegate?) { - self.modalText = modalText + init( + titleText: String, + descriptionText: String, + delegate: ProblemsLimitReachedModalViewControllerDelegate? + ) { + self.titleText = titleText + self.descriptionText = descriptionText self.delegate = delegate super.init() } @@ -130,8 +136,8 @@ final class ProblemsLimitReachedModalViewController: PanModalPresentableViewCont contentStackView.addArrangedSubview(containerStackView) let titleLabel = UILabel() - titleLabel.text = Strings.StepQuiz.ProblemsLimitReachedModal.title - titleLabel.font = .preferredFont(forTextStyle: .title2, compatibleWith: .init(legibilityWeight: .bold)) + titleLabel.text = titleText + titleLabel.font = .preferredFont(for: .title2, weight: .bold) titleLabel.textColor = .primaryText titleLabel.lineBreakMode = .byWordWrapping titleLabel.numberOfLines = 0 @@ -139,7 +145,7 @@ final class ProblemsLimitReachedModalViewController: PanModalPresentableViewCont containerStackView.addArrangedSubview(titleLabel) let textLabel = UILabel() - textLabel.text = modalText + textLabel.text = descriptionText textLabel.font = .preferredFont(forTextStyle: .body) textLabel.textColor = .primaryText textLabel.lineBreakMode = .byWordWrapping @@ -171,7 +177,8 @@ final class ProblemsLimitReachedModalViewController: PanModalPresentableViewCont @available(iOS 17, *) #Preview { ProblemsLimitReachedModalViewController( - modalText: "Modal text goes here", + titleText: "Title text", + descriptionText: "Description text", delegate: nil ) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizStatusView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizStatusView.swift index 56563f0509..fc93d41868 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizStatusView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizStatusView.swift @@ -46,7 +46,6 @@ struct StepQuizStatusView: View { case wrong case evaluation case loading - case unsupportedQuiz case invalidReply(message: String) static var allCases: [StepQuizStatusView.State] { @@ -55,7 +54,6 @@ struct StepQuizStatusView: View { .wrong, .evaluation, .loading, - .unsupportedQuiz, .invalidReply(message: "Invalid reply") ] } @@ -64,7 +62,7 @@ struct StepQuizStatusView: View { switch self { case .correct: return Images.StepQuiz.checkmark - case .wrong, .unsupportedQuiz, .invalidReply: + case .wrong, .invalidReply: return Images.StepQuiz.info case .evaluation, .loading: return "" @@ -81,8 +79,6 @@ struct StepQuizStatusView: View { return Strings.StepQuiz.quizStatusEvaluation case .loading: return Strings.StepQuiz.quizStatusLoading - case .unsupportedQuiz: - return Strings.StepQuiz.unsupportedText case .invalidReply(let message): return message } @@ -92,7 +88,7 @@ struct StepQuizStatusView: View { switch self { case .correct: return Color(ColorPalette.secondary) - case .wrong, .evaluation, .loading, .unsupportedQuiz, .invalidReply: + case .wrong, .evaluation, .loading, .invalidReply: return Color(ColorPalette.primary) } } @@ -101,7 +97,7 @@ struct StepQuizStatusView: View { switch self { case .correct: return Color(ColorPalette.green200Alpha12) - case .evaluation, .loading, .unsupportedQuiz, .invalidReply, .wrong: + case .evaluation, .loading, .invalidReply, .wrong: return Color(ColorPalette.blue200Alpha12) } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizUnsupportedView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizUnsupportedView.swift new file mode 100644 index 0000000000..79f8e92644 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizUnsupportedView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +extension StepQuizUnsupportedView { + enum Appearance { + static let illustrationMaxHeight: CGFloat = 130 + + static let rootSpacing = LayoutInsets.defaultInset * 2 + static let labelsSpacing = LayoutInsets.smallInset + static let buttonsSpacing = LayoutInsets.defaultInset + } +} + +struct StepQuizUnsupportedView: View { + let onSolveButtonTap: () -> Void + let onGoToStudyPlanButtonTap: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: Appearance.rootSpacing) { + Image(.stepQuizUnsupportedIllustration) + .renderingMode(.original) + .resizable() + .aspectRatio(contentMode: .fit) + .frame( + maxWidth: .infinity, + maxHeight: Appearance.illustrationMaxHeight, + alignment: .leading + ) + + VStack(alignment: .leading, spacing: Appearance.labelsSpacing) { + Text(Strings.StepQuiz.unsupportedTitle) + .font(.title2.bold()) + + Text(Strings.StepQuiz.unsupportedDescription) + .font(.body) + } + .foregroundColor(.newPrimaryText) + .multilineTextAlignment(.leading) + + VStack(spacing: Appearance.buttonsSpacing) { + Button( + Strings.StepQuiz.unsupportedButtonSolve, + action: onSolveButtonTap + ) + .buttonStyle(RoundedRectangleButtonStyle(style: .violet)) + + Button( + Strings.Common.goToStudyPlan, + action: onGoToStudyPlanButtonTap + ) + .buttonStyle(OutlineButtonStyle(style: .violet)) + } + } + } +} + +#if DEBUG +#Preview { + StepQuizUnsupportedView( + onSolveButtonTap: {}, + onGoToStudyPlanButtonTap: {} + ) + .padding() +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizView.swift index 4197fe16db..504b2f80a5 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizView.swift @@ -58,11 +58,9 @@ struct StepQuizView: View { ScrollView { VStack(alignment: .leading, spacing: appearance.interItemSpacing) { if case .unsupported = viewData.quizType { - StepQuizStatusView(state: .unsupportedQuiz) - - LatexView( - text: viewData.stepText, - configuration: .stepText() + StepQuizUnsupportedView( + onSolveButtonTap: viewModel.doUnsupportedQuizSolveOnTheWebAction, + onGoToStudyPlanButtonTap: viewModel.doUnsupportedQuizGoToStudyPlanAction ) } else { StepExpandableStepTextView( @@ -98,7 +96,7 @@ struct StepQuizView: View { Spacer(minLength: fillBlanksSelectOptionsViewHeight) } } - .bottomFillBlanksSelectOptionsOverlay( + .safeAreaInsetBottomCompatibility( buildFillBlanksSelectOptionsView( quizType: viewData.quizType, attemptLoadedState: StepQuizStateExtentionsKt.attemptLoadedState(viewModel.state.stepQuizState) @@ -119,9 +117,10 @@ struct StepQuizView: View { } } + // swiftlint:disable function_parameter_count @ViewBuilder private func buildQuizContent( - state: StepQuizFeatureState, + state: StepQuizFeature.State, step: Step, quizName: String?, quizType: StepQuizChildQuizType, @@ -151,6 +150,7 @@ struct StepQuizView: View { StepQuizSkeletonViewFactory.makeSkeleton(for: quizType) } } + // swiftlint:enable function_parameter_count @ViewBuilder private func buildChildQuiz( @@ -195,7 +195,7 @@ struct StepQuizView: View { @ViewBuilder private func buildQuizActionButtons( quizType: StepQuizChildQuizType, - state: StepQuizFeatureState, + state: StepQuizFeature.State, attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded ) -> some View { let submissionStatus: SubmissionStatus? = { @@ -285,6 +285,7 @@ struct StepQuizView: View { } } ) + .background(TransparentBlurView()) .edgesIgnoringSafeArea(.all) .frame(height: fillBlanksSelectOptionsViewHeight) .disabled(!StepQuizResolver.shared.isQuizEnabled(state: attemptLoadedState)) @@ -298,23 +299,49 @@ struct StepQuizView: View { case .requestResetCode: presentResetCodePermissionAlert() case .showProblemsLimitReachedModal(let showProblemsLimitReachedModalViewAction): - presentProblemsLimitReachedModal(modalText: showProblemsLimitReachedModalViewAction.modalText) + presentProblemsLimitReachedModal( + modalData: showProblemsLimitReachedModalViewAction.modalData + ) case .showProblemOnboardingModal(let showProblemOnboardingModalViewAction): presentProblemOnboardingModal(modalType: showProblemOnboardingModalViewAction.modalType) case .navigateTo(let viewActionNavigateTo): switch StepQuizFeatureActionViewActionNavigateToKs(viewActionNavigateTo) { case .home: - stackRouter.popViewController() - TabBarRouter(tab: .home).route() + stackRouter.popViewController( + animated: true, + completion: { + TabBarRouter(tab: .home).route() + } + ) case .stepScreen(let navigateToStepScreenViewAction): let assembly = StepAssembly(stepRoute: navigateToStepScreenViewAction.stepRoute) stackRouter.pushViewController(assembly.makeModule()) + case .studyPlan: + stackRouter.popViewController( + animated: true, + completion: { + TabBarRouter(tab: .studyPlan).route() + } + ) + case .paywall: + #warning("TODO: ALTAPPS-1121") } case .stepQuizHintsViewAction(let stepQuizHintsViewAction): switch StepQuizHintsFeatureActionViewActionKs(stepQuizHintsViewAction.viewAction) { case .showNetworkError: ProgressHUD.showError(status: Strings.Common.connectionError) } + case .createMagicLinkState(let createMagicLinkStateViewAction): + switch StepQuizFeatureActionViewActionCreateMagicLinkStateKs(createMagicLinkStateViewAction) { + case .error: + ProgressHUD.showError() + case .loading: + ProgressHUD.show() + case .success: + ProgressHUD.showSuccess() + } + case .openUrl(let data): + WebControllerManager.shared.presentWebControllerWithURLString(data.url, controllerType: .inAppSafari) } } } @@ -350,9 +377,10 @@ private extension StepQuizView { modalRouter.presentAlert(alert) } - func presentProblemsLimitReachedModal(modalText: String) { + func presentProblemsLimitReachedModal(modalData: StepQuizFeature.ProblemsLimitReachedModalData) { let panModal = ProblemsLimitReachedModalViewController( - modalText: modalText, + titleText: modalData.title, + descriptionText: modalData.description_, delegate: viewModel ) panModalPresenter.presentPanModal(panModal) @@ -366,22 +394,3 @@ private extension StepQuizView { panModalPresenter.presentPanModal(panModal) } } - -// MARK: - View (bottomFillBlanksSelectOptionsOverlay) - - -@available(iOS, introduced: 13, deprecated: 15, message: "Use .safeAreaInset() directly") -private extension View { - @ViewBuilder - func bottomFillBlanksSelectOptionsOverlay(_ overlayContent: OverlayContent) -> some View { - if #available(iOS 15.0, *) { - self.safeAreaInset( - edge: .bottom, - alignment: .center, - spacing: 0, - content: { overlayContent } - ) - } else { - self.overlay(overlayContent, alignment: .bottom) - } - } -} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizChoice/Views/StepQuizChoiceElementView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizChoice/Views/StepQuizChoiceElementView.swift index 06c8efaec7..aea59d7ac5 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizChoice/Views/StepQuizChoiceElementView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizChoice/Views/StepQuizChoiceElementView.swift @@ -25,10 +25,10 @@ struct StepQuizChoiceElementView: View { Button(action: onTap) { HStack(spacing: appearance.interItemSpacing) { if isMultipleChoice { - CheckboxButton(isSelected: .constant(isSelected), onClick: onTap) + CheckboxButton(isSelected: isSelected, onClick: onTap) .frame(widthHeight: appearance.checkboxIndicatorWidthHeight) } else { - RadioButton(isSelected: .constant(isSelected), onClick: onTap) + RadioButton(isSelected: isSelected, onClick: onTap) .frame(widthHeight: appearance.radioIndicatorWidthHeight) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCode/ViewData/StepQuizCodeViewDataMapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCode/ViewData/StepQuizCodeViewDataMapper.swift index 219b8b9d25..450fc60068 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCode/ViewData/StepQuizCodeViewDataMapper.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizCode/ViewData/StepQuizCodeViewDataMapper.swift @@ -18,7 +18,7 @@ class StepQuizCodeViewDataMapper { let languageStringValue = reply?.language ?? blockOptions.limits?.first?.key let language: CodeLanguage? = { - if let languageStringValue = languageStringValue { + if let languageStringValue { return CodeLanguage(rawValue: languageStringValue) } return nil @@ -26,13 +26,13 @@ class StepQuizCodeViewDataMapper { let languageHumanReadableName = step.displayLanguage ?? language?.humanReadableName let codeTemplate: String? = { - guard let languageStringValue = languageStringValue else { + guard let languageStringValue else { return nil } if let codeTemplate = blockOptions.codeTemplates?[languageStringValue] { return codeTemplate - } else if let language = language { + } else if let language { return CodeLanguageSamples.sample(for: language) } @@ -55,7 +55,7 @@ class StepQuizCodeViewDataMapper { // MARK: Private API private func mapSamples(_ samples: [[String]]?) -> [StepQuizCodeViewData.Sample] { - guard let samples = samples else { + guard let samples else { return [] } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksTextCollectionViewCell.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksTextCollectionViewCell.swift index 208c046e5b..dd6f3f1751 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksTextCollectionViewCell.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksTextCollectionViewCell.swift @@ -12,7 +12,7 @@ final class FillBlanksTextCollectionViewCell: UICollectionViewCell, Reusable { private static var prototypeTextLabel: UILabel? private lazy var textLabel: UILabel = { - Self.makeTextLabel(appearance: self.appearance) + Self.makeTextLabel(appearance: appearance) }() var appearance = Appearance() diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Input/FillBlanksInputCollectionViewCell.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Input/FillBlanksInputCollectionViewCell.swift index fe5a67d76c..6da0a7fcd2 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Input/FillBlanksInputCollectionViewCell.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Input/FillBlanksInputCollectionViewCell.swift @@ -18,19 +18,18 @@ final class FillBlanksInputCollectionViewCell: UICollectionViewCell, Reusable { var appearance = Appearance() private lazy var inputContainerView: FillBlanksInputContainerView = { - let view = FillBlanksInputContainerView( - appearance: .init(cornerRadius: self.appearance.cornerRadius) + FillBlanksInputContainerView( + appearance: .init(cornerRadius: appearance.cornerRadius) ) - return view }() private lazy var textField: UITextField = { let textField = UITextField() textField.font = Appearance.font - textField.textColor = self.appearance.textColor + textField.textColor = appearance.textColor textField.textAlignment = .center textField.delegate = self - textField.addTarget(self, action: #selector(self.textFieldDidChange(_:)), for: .editingChanged) + textField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged) // Disable features textField.autocapitalizationType = .none textField.autocorrectionType = .no diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Select/FillBlanksSelectCollectionViewCell.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Select/FillBlanksSelectCollectionViewCell.swift index 73c35af33a..221c8684bd 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Select/FillBlanksSelectCollectionViewCell.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/Select/FillBlanksSelectCollectionViewCell.swift @@ -22,14 +22,13 @@ final class FillBlanksSelectCollectionViewCell: UICollectionViewCell, Reusable { var appearance = Appearance() private lazy var inputContainerView: FillBlanksSelectContainerView = { - let view = FillBlanksSelectContainerView( - appearance: .init(cornerRadius: self.appearance.cornerRadius) + FillBlanksSelectContainerView( + appearance: .init(cornerRadius: appearance.cornerRadius) ) - return view }() private lazy var textLabel: UILabel = { - Self.makeTextLabel(appearance: self.appearance) + Self.makeTextLabel(appearance: appearance) }() var text: String? { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizTitleView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizTitleView.swift index bc07e7742e..37b451d025 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizTitleView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizTitleView.swift @@ -17,8 +17,8 @@ final class FillBlanksQuizTitleView: UIView { private lazy var titleLabel: UILabel = { let label = UILabel() label.text = Strings.StepQuizFillBlanks.title - label.textColor = self.appearance.textColor - label.font = self.appearance.font + label.textColor = appearance.textColor + label.font = appearance.font label.numberOfLines = 1 return label }() diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift index 15f0de43a9..013e871236 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift @@ -24,15 +24,15 @@ final class FillBlanksQuizView: UIView { private lazy var collectionView: UICollectionView = { let collectionViewLayout = LeftAlignedCollectionViewFlowLayout() collectionViewLayout.scrollDirection = .vertical - collectionViewLayout.minimumLineSpacing = self.appearance.collectionViewMinLineSpacing - collectionViewLayout.minimumInteritemSpacing = self.appearance.collectionViewMinInteritemSpacing - collectionViewLayout.sectionInset = self.appearance.collectionViewSectionInset + collectionViewLayout.minimumLineSpacing = appearance.collectionViewMinLineSpacing + collectionViewLayout.minimumInteritemSpacing = appearance.collectionViewMinInteritemSpacing + collectionViewLayout.sectionInset = appearance.collectionViewSectionInset let collectionView = UICollectionView( frame: .zero, collectionViewLayout: collectionViewLayout ) - collectionView.backgroundColor = self.appearance.backgroundColor + collectionView.backgroundColor = appearance.backgroundColor collectionView.isScrollEnabled = false collectionView.register(cellClass: FillBlanksInputCollectionViewCell.self) collectionView.register(cellClass: FillBlanksTextCollectionViewCell.self) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/StepQuizFillBlanksSelectOptionsViewWrapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/StepQuizFillBlanksSelectOptionsViewWrapper.swift index 7ae7a5a8b0..4caec7b460 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/StepQuizFillBlanksSelectOptionsViewWrapper.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/StepQuizFillBlanksSelectOptionsViewWrapper.swift @@ -41,6 +41,7 @@ struct StepQuizFillBlanksSelectOptionsViewWrapper: UIViewRepresentable { ) } context.coordinator.onDidSelectComponent = { + // swiftlint:disable:next closure_parameter_position [weak uiView, weak collectionViewAdapter, weak moduleOutput] indexPath in guard let uiView, let collectionViewAdapter else { return diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/Cell/StepQuizFillBlanksSelectOptionsCollectionViewCell.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/Cell/StepQuizFillBlanksSelectOptionsCollectionViewCell.swift index def7e55bb2..c2578600f9 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/Cell/StepQuizFillBlanksSelectOptionsCollectionViewCell.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/Cell/StepQuizFillBlanksSelectOptionsCollectionViewCell.swift @@ -22,14 +22,13 @@ final class StepQuizFillBlanksSelectOptionsCollectionViewCell: UICollectionViewC var appearance = Appearance() private lazy var inputContainerView: StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView = { - let view = StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView( - appearance: .init(cornerRadius: self.appearance.cornerRadius) + StepQuizFillBlanksSelectOptionsCollectionViewCellContainerView( + appearance: .init(cornerRadius: appearance.cornerRadius) ) - return view }() private lazy var textLabel: UILabel = { - Self.makeTextLabel(appearance: self.appearance) + Self.makeTextLabel(appearance: appearance) }() var text: String? { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/StepQuizFillBlanksSelectOptionsView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/StepQuizFillBlanksSelectOptionsView.swift index d5f3c2b8df..67336f6c56 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/StepQuizFillBlanksSelectOptionsView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanksSelectOptions/Views/UIKit/StepQuizFillBlanksSelectOptionsView.swift @@ -9,7 +9,7 @@ extension StepQuizFillBlanksSelectOptionsView { let collectionViewMinInteritemSpacing = LayoutInsets.defaultInset let collectionViewSectionInset = LayoutInsets.default.uiEdgeInsets - let backgroundColor = UIColor.systemBackground + let backgroundColor = UIColor.clear } } @@ -19,15 +19,15 @@ final class StepQuizFillBlanksSelectOptionsView: UIView { private lazy var collectionView: UICollectionView = { let collectionViewLayout = LeftAlignedCollectionViewFlowLayout() collectionViewLayout.scrollDirection = .vertical - collectionViewLayout.minimumLineSpacing = self.appearance.collectionViewMinLineSpacing - collectionViewLayout.minimumInteritemSpacing = self.appearance.collectionViewMinInteritemSpacing - collectionViewLayout.sectionInset = self.appearance.collectionViewSectionInset + collectionViewLayout.minimumLineSpacing = appearance.collectionViewMinLineSpacing + collectionViewLayout.minimumInteritemSpacing = appearance.collectionViewMinInteritemSpacing + collectionViewLayout.sectionInset = appearance.collectionViewSectionInset let collectionView = UICollectionView( frame: .zero, collectionViewLayout: collectionViewLayout ) - collectionView.backgroundColor = self.appearance.backgroundColor + collectionView.backgroundColor = appearance.backgroundColor collectionView.isScrollEnabled = false collectionView.automaticallyAdjustsScrollIndicatorInsets = false collectionView.register(cellClass: StepQuizFillBlanksSelectOptionsCollectionViewCell.self) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizString/StepQuizStringViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizString/StepQuizStringViewModel.swift index 5539c57d8d..b21e28304b 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizString/StepQuizStringViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizString/StepQuizStringViewModel.swift @@ -19,7 +19,7 @@ final class StepQuizStringViewModel: ObservableObject, StepQuizChildQuizInputPro self.reply = reply let text: String = { () -> String? in - guard let reply = reply else { + guard let reply else { return nil } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTable/StepQuizTableViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTable/StepQuizTableViewModel.swift index 9a11b4f6c5..4ff40cf275 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTable/StepQuizTableViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTable/StepQuizTableViewModel.swift @@ -39,7 +39,7 @@ final class StepQuizTableViewModel: ObservableObject, StepQuizChildQuizInputProt } func makeSelectColumnsViewController(for row: StepQuizTableViewData.Row) -> PanModalPresentableViewController { - let viewController = StepQuizTableSelectColumnsViewController( + StepQuizTableSelectColumnsViewController( row: row, columns: viewData.columns, selectedColumnsIDs: Set(row.answers.map(\.id)), @@ -59,8 +59,6 @@ final class StepQuizTableViewModel: ObservableObject, StepQuizChildQuizInputProt strongSelf.outputCurrentReply() } ) - - return viewController } func createReply() -> Reply { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTable/Views/StepQuizTableRowView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTable/Views/StepQuizTableRowView.swift index 2403a58d69..ee9e752e12 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTable/Views/StepQuizTableRowView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTable/Views/StepQuizTableRowView.swift @@ -38,7 +38,7 @@ struct StepQuizTableRowView: View { ) ) - if let subtitle = subtitle, !subtitle.isEmpty { + if let subtitle, !subtitle.isEmpty { LatexView( text: subtitle, configuration: .quizContent( diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTableSelectColumns/Views/StepQuizTableSelectColumnsColumnView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTableSelectColumns/Views/StepQuizTableSelectColumnsColumnView.swift index 6375bb0d6a..1554ac4d2b 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTableSelectColumns/Views/StepQuizTableSelectColumnsColumnView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTableSelectColumns/Views/StepQuizTableSelectColumnsColumnView.swift @@ -14,7 +14,7 @@ extension StepQuizTableSelectColumnsColumnView { struct StepQuizTableSelectColumnsColumnView: View { private(set) var appearance = Appearance() - var isSelected: Binding + let isSelected: Bool let text: String @@ -37,7 +37,7 @@ struct StepQuizTableSelectColumnsColumnView: View { } @ViewBuilder - private func buildIndicator(isSelected: Binding, onTap: @escaping () -> Void) -> some View { + private func buildIndicator(isSelected: Bool, onTap: @escaping () -> Void) -> some View { if isMultipleChoice { CheckboxButton( appearance: .init(backgroundUnselectedColor: .clear), @@ -56,23 +56,22 @@ struct StepQuizTableSelectColumnsColumnView: View { } } -struct StepQuizTableSelectColumnsColumnView_Previews: PreviewProvider { - static var previews: some View { - Group { - StepQuizTableSelectColumnsColumnView( - isSelected: .constant(true), - text: "Some option", - isMultipleChoice: false, - onTap: {} - ) - StepQuizTableSelectColumnsColumnView( - isSelected: .constant(true), - text: "Some option", - isMultipleChoice: true, - onTap: {} - ) - } - .previewLayout(.sizeThatFits) - .padding() +#if DEBUG +#Preview { + VStack { + StepQuizTableSelectColumnsColumnView( + isSelected: true, + text: "Some option", + isMultipleChoice: false, + onTap: {} + ) + StepQuizTableSelectColumnsColumnView( + isSelected: true, + text: "Some option", + isMultipleChoice: true, + onTap: {} + ) } + .padding() } +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTableSelectColumns/Views/StepQuizTableSelectColumnsView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTableSelectColumns/Views/StepQuizTableSelectColumnsView.swift index ccc52af19b..af318b2bde 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTableSelectColumns/Views/StepQuizTableSelectColumnsView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizTableSelectColumns/Views/StepQuizTableSelectColumnsView.swift @@ -47,7 +47,7 @@ struct StepQuizTableSelectColumnsView: View { VStack(alignment: .leading, spacing: 0) { ForEach(columns) { column in StepQuizTableSelectColumnsColumnView( - isSelected: .constant(selectedColumnsIDs.contains(column.id)), + isSelected: selectedColumnsIDs.contains(column.id), text: column.text, isMultipleChoice: isMultipleChoice, onTap: { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Streak/Views/Modals/StreakFreezeModalViewController.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Streak/Views/Modals/StreakFreezeModalViewController.swift index 88383b3fac..cbb549aac8 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Streak/Views/Modals/StreakFreezeModalViewController.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Streak/Views/Modals/StreakFreezeModalViewController.swift @@ -109,7 +109,7 @@ final class StreakFreezeModalViewController: PanModalPresentableViewController { private func setupTitleView() { let label = UILabel() label.text = streakFreezeState.title - label.font = .preferredFont(forTextStyle: .largeTitle, compatibleWith: .init(legibilityWeight: .bold)) + label.font = .preferredFont(for: .largeTitle, weight: .bold) label.textColor = .primaryText label.lineBreakMode = .byWordWrapping label.numberOfLines = 0 diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanAssembly.swift index 58e6925ebc..0006834f6b 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanAssembly.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanAssembly.swift @@ -8,7 +8,7 @@ final class StudyPlanAssembly: UIKitAssembly { feature: studyPlanScreenComponent.studyPlanScreenFeature ) - let stackRouter = SwiftUIStackRouter() + let stackRouter = StackRouter() let panModalPresenter = PanModalPresenter() let trackView = StudyPlanView( diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanViewModel.swift index 9a505f67c4..dfac72e206 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanViewModel.swift @@ -12,6 +12,9 @@ final class StudyPlanViewModel: FeatureViewModel< var studyPlanWidgetStateKs: StudyPlanWidgetViewStateKs { .init(state.studyPlanWidgetViewState) } var gamificationToolbarViewStateKs: GamificationToolbarFeatureViewStateKs { .init(state.toolbarViewState) } var problemsLimitViewStateKs: ProblemsLimitFeatureViewStateKs { .init(state.problemsLimitViewState) } + var usersQuestionnaireWidgetFeatureStateKs: UsersQuestionnaireWidgetFeatureStateKs { + .init(state.usersQuestionnaireWidgetState) + } override func shouldNotifyStateDidChange( oldState: StudyPlanScreenFeature.ViewState, @@ -91,7 +94,7 @@ final class StudyPlanViewModel: FeatureViewModel< func doReloadProblemsLimit() { onNewMessage( StudyPlanScreenFeatureMessageProblemsLimitMessage( - message: ProblemsLimitFeatureMessageInitialize(forceUpdate: true) + message: ProblemsLimitFeatureMessageRetryContentLoading() ) ) } @@ -138,3 +141,31 @@ extension StudyPlanViewModel: StageImplementUnsupportedModalViewControllerDelega ) } } + +// MARK: - StudyPlanViewModel: UsersQuestionnaireWidgetOutputProtocol - + +extension StudyPlanViewModel: UsersQuestionnaireWidgetOutputProtocol { + func handleUsersQuestionnaireWidgetClicked() { + onNewMessage( + StudyPlanScreenFeatureMessageUsersQuestionnaireWidgetMessage( + message: UsersQuestionnaireWidgetFeatureMessageWidgetClicked() + ) + ) + } + + func handleUsersQuestionnaireWidgetCloseClicked() { + onNewMessage( + StudyPlanScreenFeatureMessageUsersQuestionnaireWidgetMessage( + message: UsersQuestionnaireWidgetFeatureMessageCloseClicked() + ) + ) + } + + func handleUsersQuestionnaireWidgetDidAppear() { + onNewMessage( + StudyPlanScreenFeatureMessageUsersQuestionnaireWidgetMessage( + message: UsersQuestionnaireWidgetFeatureMessageViewedEventMessage() + ) + ) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/Views/StudyPlanView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/Views/StudyPlanView.swift index 1d0381d351..548f6283b6 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/Views/StudyPlanView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/Views/StudyPlanView.swift @@ -14,8 +14,8 @@ struct StudyPlanView: View { @StateObject var viewModel: StudyPlanViewModel - @StateObject var stackRouter: SwiftUIStackRouter - @StateObject var panModalPresenter: PanModalPresenter + let stackRouter: StackRouterProtocol + let panModalPresenter: PanModalPresenter var body: some View { ZStack { @@ -29,6 +29,7 @@ struct StudyPlanView: View { BackgroundView(color: appearance.backgroundColor) buildBody() + .animation(.default, value: viewModel.state) } .navigationTitle(Strings.StudyPlan.title) .navigationViewStyle(StackNavigationViewStyle()) @@ -84,6 +85,15 @@ struct StudyPlanView: View { onReloadButtonTap: viewModel.doReloadProblemsLimit ) + let usersQuestionnaireWidgetFeatureStateKs = viewModel.usersQuestionnaireWidgetFeatureStateKs + if usersQuestionnaireWidgetFeatureStateKs != .hidden { + UsersQuestionnaireWidgetAssembly( + stateKs: usersQuestionnaireWidgetFeatureStateKs, + moduleOutput: viewModel + ) + .makeModule() + } + ForEach(data.sections, id: \.id) { section in StudyPlanSectionView( section: section, @@ -125,6 +135,10 @@ private extension StudyPlanView { handleStudyPlanWidgetViewAction( studyPlanWidgetViewAction.viewAction ) + case .usersQuestionnaireWidgetViewAction(let usersQuestionnaireWidgetViewAction): + handleUsersQuestionnaireWidgetViewAction( + usersQuestionnaireWidgetViewAction.viewAction + ) } } @@ -182,6 +196,18 @@ private extension StudyPlanView { } } } + + func handleUsersQuestionnaireWidgetViewAction( + _ viewAction: UsersQuestionnaireWidgetFeatureActionViewAction + ) { + switch UsersQuestionnaireWidgetFeatureActionViewActionKs(viewAction) { + case .showUsersQuestionnaire(let showUsersQuestionnaireViewAction): + WebControllerManager.shared.presentWebControllerWithURLString( + showUsersQuestionnaireViewAction.url, + controllerType: .inAppSafari + ) + } + } } // MARK: - StudyPlanView_Previews: PreviewProvider - diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/SubscriptionDetails/SubscriptionDetailsView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/SubscriptionDetails/SubscriptionDetailsView.swift new file mode 100644 index 0000000000..c642c4ca1f --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/SubscriptionDetails/SubscriptionDetailsView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +extension SubscriptionDetailsView { + enum Appearance { + static let spacing = LayoutInsets.defaultInset + LayoutInsets.smallInset + static let interItemSpacing = LayoutInsets.smallInset + + static let noticeBadgeAppearance = BadgeView.Appearance( + cornerRadius: LayoutInsets.defaultInset / 2, + insets: .default, + font: .body + ) + } +} + +struct SubscriptionDetailsView: View { + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: Appearance.spacing) { + header + details + notice + } + .padding() + } + .scrollBounceBehaviorBasedOnSize() + .safeAreaInsetBottomCompatibility(footer) + } + + private var header: some View { + VStack(alignment: .leading, spacing: Appearance.interItemSpacing) { + Text("Your current plan:") + .font(.callout) + Text("Hyperskill Mobile only") + .font(.title.bold()) + Text("Valid until January 27, 2024, 02:00") + .font(.body) + } + .foregroundColor(.newPrimaryText) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var details: some View { + VStack(alignment: .leading, spacing: Appearance.interItemSpacing) { + Text("Plan details:") + .font(.callout.bold()) + PaywallFeaturesView() + } + } + + private var notice: some View { + BadgeView( + appearance: Appearance.noticeBadgeAppearance, + text: """ +Please be aware that with the Mobile ะพnly plan on hyperskill.org, \ +there is a limit on the number of problems you can solve per day. +""", + style: .blue + ) + } + + @MainActor private var footer: some View { + Button( + "Manage subscription", + action: { + FeedbackGenerator(feedbackType: .selection).triggerFeedback() + } + ) + .buttonStyle(RoundedRectangleButtonStyle(style: .violet)) + .padding() + .background( + TransparentBlurView() + .edgesIgnoringSafeArea(.all) + ) + .fixedSize(horizontal: false, vertical: true) + } +} + +#Preview { + SubscriptionDetailsView() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/TrackSelection/Details/Views/Content/TrackSelectionDetailsContentView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/TrackSelection/Details/Views/Content/TrackSelectionDetailsContentView.swift index 052286b469..d1281ce8e7 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/TrackSelection/Details/Views/Content/TrackSelectionDetailsContentView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/TrackSelection/Details/Views/Content/TrackSelectionDetailsContentView.swift @@ -3,18 +3,6 @@ import SwiftUI extension TrackSelectionDetailsContentView { struct Appearance { let spacing = LayoutInsets.defaultInset - - let callToActionBlurInsets = LayoutInsets( - horizontal: LayoutInsets.smallInset, - vertical: -LayoutInsets.smallInset - ) - - func makeCallToActionButtonStyle(isEnabled: Bool) -> RoundedRectangleButtonStyle { - var style = RoundedRectangleButtonStyle(style: .violet) - style.backgroundDisabledOpacity = 1 - style.foregroundColor = isEnabled ? Color(ColorPalette.onPrimary) : Color(ColorPalette.onPrimaryAlpha60) - return style - } } } @@ -42,13 +30,7 @@ struct TrackSelectionDetailsContentView: View { let isCallToActionButtonEnabled: Bool let onCallToActionButtonTap: () -> Void - private let callToActionButtonFeedbackGenerator = FeedbackGenerator(feedbackType: .selection) - var body: some View { - let callToActionButtonStyle = appearance.makeCallToActionButtonStyle( - isEnabled: isCallToActionButtonEnabled - ) - ScrollView { VStack(spacing: appearance.spacing) { TrackSelectionDetailsDescriptionView( @@ -74,36 +56,32 @@ struct TrackSelectionDetailsContentView: View { ) } .padding() - .padding(.bottom, callToActionButtonStyle.minHeight) } .frame(maxWidth: .infinity) .navigationTitle(navigationTitle) - .overlay( - buildCallToActionButton(buttonStyle: callToActionButtonStyle), - alignment: .bottom - ) + .safeAreaInsetBottomCompatibility(footerView) } - @MainActor - @ViewBuilder - private func buildCallToActionButton( - buttonStyle: RoundedRectangleButtonStyle - ) -> some View { - Button( - Strings.TrackSelectionDetails.callToActionButtonTitle, - action: { - callToActionButtonFeedbackGenerator.triggerFeedback() - onCallToActionButtonTap() - } - ) - .buttonStyle(buttonStyle) - .padding(.horizontal) - .background( - Color(ColorPalette.surface) - .padding(appearance.callToActionBlurInsets.edgeInsets) - .blur(radius: buttonStyle.cornerRadius) - ) - .disabled(!isCallToActionButtonEnabled) + @MainActor @ViewBuilder private var footerView: some View { + if isCallToActionButtonEnabled { + Button( + Strings.TrackSelectionDetails.callToActionButtonTitle, + action: { + FeedbackGenerator(feedbackType: .selection).triggerFeedback() + onCallToActionButtonTap() + } + ) + .buttonStyle(.primary) + .shineEffect() + .padding() + .background( + TransparentBlurView() + .edgesIgnoringSafeArea(.all) + ) + .fixedSize(horizontal: false, vertical: true) + } else { + EmptyView() + } } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/TrackSelection/Details/Views/Skeleton/TrackSelectionDetailsSkeletonView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/TrackSelection/Details/Views/Skeleton/TrackSelectionDetailsSkeletonView.swift index 9d6c3c37ae..2ee1e29af0 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/TrackSelection/Details/Views/Skeleton/TrackSelectionDetailsSkeletonView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/TrackSelection/Details/Views/Skeleton/TrackSelectionDetailsSkeletonView.swift @@ -16,7 +16,7 @@ struct TrackSelectionDetailsSkeletonView: View { .frame(height: 240) SkeletonRoundedView() - .frame(height: 44) + .frame(height: RoundedRectangleButtonStyle.primary.minHeight) } .padding() } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/UsersQuestionnaireOnboardingAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/UsersQuestionnaireOnboardingAssembly.swift new file mode 100644 index 0000000000..f1a60c0acb --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/UsersQuestionnaireOnboardingAssembly.swift @@ -0,0 +1,31 @@ +import shared +import SwiftUI + +final class UsersQuestionnaireOnboardingAssembly: UIKitAssembly { + private weak var moduleOutput: UsersQuestionnaireOnboardingOutputProtocol? + + init(moduleOutput: UsersQuestionnaireOnboardingOutputProtocol?) { + self.moduleOutput = moduleOutput + } + + func makeModule() -> UIViewController { + let usersQuestionnaireOnboardingComponent = + AppGraphBridge.sharedAppGraph.buildUsersQuestionnaireOnboardingComponent() + + let usersQuestionnaireOnboardingViewModel = UsersQuestionnaireOnboardingViewModel( + feature: usersQuestionnaireOnboardingComponent.usersQuestionnaireOnboardingFeature + ) + usersQuestionnaireOnboardingViewModel.moduleOutput = moduleOutput + + let usersQuestionnaireOnboardingView = UsersQuestionnaireOnboardingView( + viewModel: usersQuestionnaireOnboardingViewModel + ) + + let hostingController = StyledHostingController( + rootView: usersQuestionnaireOnboardingView + ) + hostingController.navigationItem.largeTitleDisplayMode = .never + + return hostingController + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/UsersQuestionnaireOnboardingOutputProtocol.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/UsersQuestionnaireOnboardingOutputProtocol.swift new file mode 100644 index 0000000000..a2822654fd --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/UsersQuestionnaireOnboardingOutputProtocol.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol UsersQuestionnaireOnboardingOutputProtocol: AnyObject { + func handleUsersQuestionnaireOnboardingCompleted() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/UsersQuestionnaireOnboardingViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/UsersQuestionnaireOnboardingViewModel.swift new file mode 100644 index 0000000000..1449dd1e69 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/UsersQuestionnaireOnboardingViewModel.swift @@ -0,0 +1,41 @@ +import Foundation +import shared + +final class UsersQuestionnaireOnboardingViewModel: FeatureViewModel< + UsersQuestionnaireOnboardingFeature.ViewState, + UsersQuestionnaireOnboardingFeatureMessage, + UsersQuestionnaireOnboardingFeatureActionViewAction +> { + weak var moduleOutput: UsersQuestionnaireOnboardingOutputProtocol? + + override func shouldNotifyStateDidChange( + oldState: UsersQuestionnaireOnboardingFeature.ViewState, + newState: UsersQuestionnaireOnboardingFeature.ViewState + ) -> Bool { + !oldState.isEqual(newState) + } + + func selectChoice(_ choice: String) { + onNewMessage(UsersQuestionnaireOnboardingFeatureMessageClickedChoice(choice: choice)) + } + + func doTextInputValueChanged(_ value: String) { + onNewMessage(UsersQuestionnaireOnboardingFeatureMessageTextInputValueChanged(text: value)) + } + + func doSend() { + onNewMessage(UsersQuestionnaireOnboardingFeatureMessageSendButtonClicked()) + } + + func doSkip() { + onNewMessage(UsersQuestionnaireOnboardingFeatureMessageSkipButtonClicked()) + } + + func doCompleteOnboarding() { + moduleOutput?.handleUsersQuestionnaireOnboardingCompleted() + } + + func logViewedEvent() { + onNewMessage(UsersQuestionnaireOnboardingFeatureMessageViewedEventMessage()) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/Views/UsersQuestionnaireOnboardingChoicesView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/Views/UsersQuestionnaireOnboardingChoicesView.swift new file mode 100644 index 0000000000..0ef2787f17 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/Views/UsersQuestionnaireOnboardingChoicesView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +extension UsersQuestionnaireOnboardingChoicesView { + struct Appearance { + var spacing = LayoutInsets.smallInset + + let textFont = UIFont.preferredFont(forTextStyle: .body) + var radioButtonSize: CGFloat { + textFont.pointSize * 1.15 + } + + let selectedTextColor = Color.newPrimaryText + let unselectedTextColor = Color.newSecondaryText + } +} + +struct UsersQuestionnaireOnboardingChoicesView: View { + private(set) var appearance = Appearance() + + let choices: [String] + let selectedChoice: String? + + let onTap: (String) -> Void + + var body: some View { + VStack(spacing: appearance.spacing) { + ForEach(choices, id: \.self) { choice in + Button( + action: { + withAnimation { + handleTap(on: choice) + } + }, + label: { + let isSelected = choice == selectedChoice + + HStack(spacing: appearance.spacing) { + RadioButton( + appearance: .init( + borderUnselectedColor: appearance.unselectedTextColor + ), + isSelected: isSelected, + onClick: { + withAnimation { + handleTap(on: choice) + } + } + ) + .frame(widthHeight: appearance.radioButtonSize) + + Text(choice) + .font(Font(appearance.textFont)) + .foregroundColor( + isSelected ? appearance.selectedTextColor : appearance.unselectedTextColor + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, appearance.spacing) + .animation(.default, value: isSelected) + } + ) + } + } + } + + @MainActor + private func handleTap(on choice: String) { + FeedbackGenerator(feedbackType: .selection).triggerFeedback() + onTap(choice) + } +} + +#if DEBUG +#Preview { + UsersQuestionnaireOnboardingChoicesView( + choices: QuestionnaireOnboardingPreviewDefaults.choices, + selectedChoice: QuestionnaireOnboardingPreviewDefaults.choices.first, + onTap: { _ in } + ) + .padding() +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/Views/UsersQuestionnaireOnboardingContentView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/Views/UsersQuestionnaireOnboardingContentView.swift new file mode 100644 index 0000000000..9b34e8b6b0 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/Views/UsersQuestionnaireOnboardingContentView.swift @@ -0,0 +1,140 @@ +import Combine +import SwiftUI + +extension UsersQuestionnaireOnboardingContentView { + enum Appearance { + static let spacing = LayoutInsets.defaultInset + static let interitemSpacing = LayoutInsets.smallInset + + static let textInputHeight: CGFloat = 58 + static let textInputPlaceholderInsets = EdgeInsets(top: 16, leading: 13, bottom: 16, trailing: 16) + } +} + +struct UsersQuestionnaireOnboardingContentView: View { + let title: String + + let choices: [String] + let selectedChoice: String? + let onChoiceTap: (String) -> Void + + var textInputValue: Binding + let isTextInputVisible: Bool + + let isSendButtonEnabled: Bool + let onSendButtotTap: () -> Void + let onSkipButtotTap: () -> Void + + @State private var isKeyboardVisible = false + + var body: some View { + ScrollView { + VStack(spacing: Appearance.spacing) { + Text(title) + .font(.title).bold() + .foregroundColor(.newPrimaryText) + .multilineTextAlignment(.center) + + UsersQuestionnaireOnboardingChoicesView( + appearance: .init(spacing: Appearance.interitemSpacing), + choices: choices, + selectedChoice: selectedChoice, + onTap: onChoiceTap + ) + .padding(.vertical) + + if isTextInputVisible { + textInput + .animation(nil) + } + } + .introspectScrollView { scrollView in + scrollView.shouldIgnoreScrollingAdjustment = true + } + .padding() + } + .scrollBounceBehaviorBasedOnSize() + .safeAreaInsetBottomCompatibility(footerView) + .onReceive(Publishers.keyboardIsVisible) { isKeyboardVisible = $0 } + } + + private var textInput: some View { + TextEditor(text: textInputValue) + .foregroundColor(.newPrimaryText) + .font(.body) + .multilineTextAlignment(.leading) + .keyboardType(.asciiCapable) + .disableAutocorrection(true) + .frame(height: Appearance.textInputHeight) + .frame(maxWidth: .infinity) + .padding(LayoutInsets.small.edgeInsets) + .overlay( + Text(Strings.UsersQuestionnaireOnboarding.textInputPlaceholder) + .font(.body) + .foregroundColor(.newSecondaryText) + .allowsHitTesting(false) + .padding(Appearance.textInputPlaceholderInsets) + .opacity(textInputValue.wrappedValue.isEmpty ? 1 : 0) + , + alignment: .topLeading + ) + .addBorder() + } + + @ViewBuilder private var footerView: some View { + if isKeyboardVisible { + EmptyView() + } else { + UsersQuestionnaireOnboardingFooterView( + appearance: .init(spacing: Appearance.interitemSpacing), + isSendButtonEnabled: isSendButtonEnabled, + onSendButtotTap: onSendButtotTap, + onSkipButtotTap: onSkipButtotTap + ) + } + } +} + +#if DEBUG +enum QuestionnaireOnboardingPreviewDefaults { + static let title = "How did you hear about\nMy Hyperskill?" + static let choices = [ + "Google Play", + "Google Search", + "YouTube", + "Instagram", + "TikTok", + "News/article/blog", + "Friends/family", + "Other" + ] +} + +#Preview { + UsersQuestionnaireOnboardingContentView( + title: QuestionnaireOnboardingPreviewDefaults.title, + choices: QuestionnaireOnboardingPreviewDefaults.choices, + selectedChoice: nil, + onChoiceTap: { _ in }, + textInputValue: .constant(""), + isTextInputVisible: false, + isSendButtonEnabled: false, + onSendButtotTap: {}, + onSkipButtotTap: {} + ) +} + +#Preview { + UsersQuestionnaireOnboardingContentView( + title: QuestionnaireOnboardingPreviewDefaults.title, + choices: QuestionnaireOnboardingPreviewDefaults.choices, + selectedChoice: QuestionnaireOnboardingPreviewDefaults.choices.last, + onChoiceTap: { _ in }, + textInputValue: .constant(""), + isTextInputVisible: true, + isSendButtonEnabled: false, + onSendButtotTap: {}, + onSkipButtotTap: {} + ) +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/Views/UsersQuestionnaireOnboardingFooterView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/Views/UsersQuestionnaireOnboardingFooterView.swift new file mode 100644 index 0000000000..a83a89b538 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/Views/UsersQuestionnaireOnboardingFooterView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +extension UsersQuestionnaireOnboardingFooterView { + struct Appearance { + var spacing = LayoutInsets.smallInset + } +} + +struct UsersQuestionnaireOnboardingFooterView: View { + private(set) var appearance = Appearance() + + private let feedbackGenerator = FeedbackGenerator(feedbackType: .selection) + + let isSendButtonEnabled: Bool + + let onSendButtotTap: () -> Void + let onSkipButtotTap: () -> Void + + var body: some View { + actionButtons + .padding() + .background( + TransparentBlurView() + .edgesIgnoringSafeArea(.all) + ) + .fixedSize(horizontal: false, vertical: true) + } + + @MainActor private var actionButtons: some View { + VStack(alignment: .center, spacing: appearance.spacing) { + Button( + Strings.UsersQuestionnaireOnboarding.sendButtot, + action: { + feedbackGenerator.triggerFeedback() + onSendButtotTap() + } + ) + .buttonStyle(.primary) + .shineEffect(isActive: isSendButtonEnabled) + .disabled(!isSendButtonEnabled) + + Button( + Strings.UsersQuestionnaireOnboarding.skipButton, + action: { + feedbackGenerator.triggerFeedback() + onSkipButtotTap() + } + ) + .buttonStyle(GhostButtonStyle()) + } + } +} + +#if DEBUG +#Preview { + VStack { + UsersQuestionnaireOnboardingFooterView( + isSendButtonEnabled: true, + onSendButtotTap: {}, + onSkipButtotTap: {} + ) + + UsersQuestionnaireOnboardingFooterView( + isSendButtonEnabled: false, + onSendButtotTap: {}, + onSkipButtotTap: {} + ) + } +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/Views/UsersQuestionnaireOnboardingView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/Views/UsersQuestionnaireOnboardingView.swift new file mode 100644 index 0000000000..e30714f0d2 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Onboarding/Views/UsersQuestionnaireOnboardingView.swift @@ -0,0 +1,61 @@ +import shared +import SwiftUI + +extension UsersQuestionnaireOnboardingView { + struct Appearance { + let backgroundColor = Color(ColorPalette.newLayer1) + } +} + +struct UsersQuestionnaireOnboardingView: View { + private(set) var appearance = Appearance() + + @StateObject var viewModel: UsersQuestionnaireOnboardingViewModel + + var body: some View { + ZStack { + UIViewControllerEventsWrapper(onViewDidAppear: viewModel.logViewedEvent) + + BackgroundView(color: appearance.backgroundColor) + + UsersQuestionnaireOnboardingContentView( + title: viewModel.state.title, + choices: viewModel.state.choices, + selectedChoice: viewModel.state.selectedChoice, + onChoiceTap: viewModel.selectChoice(_:), + textInputValue: Binding( + get: { viewModel.state.textInputValue ?? "" }, + set: { viewModel.doTextInputValueChanged($0) } + ), + isTextInputVisible: viewModel.state.isTextInputVisible, + isSendButtonEnabled: viewModel.state.isSendButtonEnabled, + onSendButtotTap: viewModel.doSend, + onSkipButtotTap: viewModel.doSkip + ) + .animation(.default, value: viewModel.state) + } + .onAppear { + viewModel.startListening() + viewModel.onViewAction = handleViewAction(_:) + } + .onDisappear { + viewModel.stopListening() + viewModel.onViewAction = nil + } + } +} + +// MARK: - UsersQuestionnaireOnboardingView (ViewAction) - + +private extension UsersQuestionnaireOnboardingView { + func handleViewAction( + _ viewAction: UsersQuestionnaireOnboardingFeatureActionViewAction + ) { + switch UsersQuestionnaireOnboardingFeatureActionViewActionKs(viewAction) { + case .showSendSuccessMessage(let showSendSuccessMessageViewAction): + ProgressHUD.showSuccess(status: showSendSuccessMessageViewAction.message) + case .completeUsersQuestionnaireOnboarding: + viewModel.doCompleteOnboarding() + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Widget/UsersQuestionnaireWidgetAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Widget/UsersQuestionnaireWidgetAssembly.swift new file mode 100644 index 0000000000..30e2d98758 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Widget/UsersQuestionnaireWidgetAssembly.swift @@ -0,0 +1,26 @@ +import shared +import SwiftUI + +final class UsersQuestionnaireWidgetAssembly: Assembly { + weak var moduleOutput: UsersQuestionnaireWidgetOutputProtocol? + + private let stateKs: UsersQuestionnaireWidgetFeatureStateKs + + init( + stateKs: UsersQuestionnaireWidgetFeatureStateKs, + moduleOutput: UsersQuestionnaireWidgetOutputProtocol? + ) { + self.stateKs = stateKs + self.moduleOutput = moduleOutput + } + + func makeModule() -> UsersQuestionnaireWidgetView { + let viewModel = UsersQuestionnaireWidgetViewModel() + viewModel.moduleOutput = moduleOutput + + return UsersQuestionnaireWidgetView( + stateKs: stateKs, + viewModel: viewModel + ) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Widget/UsersQuestionnaireWidgetOutputProtocol.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Widget/UsersQuestionnaireWidgetOutputProtocol.swift new file mode 100644 index 0000000000..fba3fa4d6b --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Widget/UsersQuestionnaireWidgetOutputProtocol.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol UsersQuestionnaireWidgetOutputProtocol: AnyObject { + func handleUsersQuestionnaireWidgetClicked() + func handleUsersQuestionnaireWidgetCloseClicked() + func handleUsersQuestionnaireWidgetDidAppear() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Widget/UsersQuestionnaireWidgetView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Widget/UsersQuestionnaireWidgetView.swift new file mode 100644 index 0000000000..62356477f4 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Widget/UsersQuestionnaireWidgetView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +extension UsersQuestionnaireWidgetView { + struct Appearance { + let skeletonHeight: CGFloat = 114 + + let spacing = LayoutInsets.defaultInset + } +} + +struct UsersQuestionnaireWidgetView: View { + private(set) var appearance = Appearance() + + let stateKs: UsersQuestionnaireWidgetFeatureStateKs + + let viewModel: UsersQuestionnaireWidgetViewModel + + var body: some View { + ZStack { + UIViewControllerEventsWrapper( + onViewDidAppear: viewModel.logViewedEvent + ) + buildBody() + } + } + + @ViewBuilder + private func buildBody() -> some View { + switch stateKs { + case .idle, .loading: + SkeletonRoundedView() + .frame(height: appearance.skeletonHeight) + case .hidden: + EmptyView() + case .visible: + Button( + action: { + withAnimation { + viewModel.doCallToAction() + } + }, + label: { + HStack(alignment: .center, spacing: 0) { + Text(Strings.UsersQuestionnaireWidget.title) + .font(.subheadline) + + Spacer() + + Button( + action: { + withAnimation { + viewModel.doCloseAction() + } + }, + label: { + Image(systemName: "xmark.circle.fill") + .padding(.all, appearance.spacing) + } + ) + .offset(x: appearance.spacing, y: 0) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.horizontal) + .background(backgroundGradient) + } + ) + .buttonStyle(BounceButtonStyle()) + } + } + + private var backgroundGradient: some View { + Image(.brandGradient3) + .renderingMode(.original) + .resizable() + .addBorder(color: .clear, width: 0) + } +} + +#Preview { + UsersQuestionnaireWidgetView( + stateKs: .visible, + viewModel: UsersQuestionnaireWidgetViewModel() + ) + .padding(.horizontal) +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Widget/UsersQuestionnaireWidgetViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Widget/UsersQuestionnaireWidgetViewModel.swift new file mode 100644 index 0000000000..be78facc8c --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/UsersQuestionnaire/Widget/UsersQuestionnaireWidgetViewModel.swift @@ -0,0 +1,17 @@ +import Foundation + +final class UsersQuestionnaireWidgetViewModel { + weak var moduleOutput: UsersQuestionnaireWidgetOutputProtocol? + + func doCallToAction() { + moduleOutput?.handleUsersQuestionnaireWidgetClicked() + } + + func doCloseAction() { + moduleOutput?.handleUsersQuestionnaireWidgetCloseClicked() + } + + func logViewedEvent() { + moduleOutput?.handleUsersQuestionnaireWidgetDidAppear() + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Services/ApplicationShortcuts/ApplicationShortcutIdentifier.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Services/ApplicationShortcuts/ApplicationShortcutIdentifier.swift new file mode 100644 index 0000000000..9bff938c8c --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Services/ApplicationShortcuts/ApplicationShortcutIdentifier.swift @@ -0,0 +1,12 @@ +import Foundation + +enum ApplicationShortcutIdentifier: String { + case sendFeedback = "SendFeedback" + + init?(fullIdentifier: String) { + guard let shortIdentifier = fullIdentifier.components(separatedBy: ".").last else { + return nil + } + self.init(rawValue: shortIdentifier) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Services/ApplicationShortcuts/ApplicationShortcutsService.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Services/ApplicationShortcuts/ApplicationShortcutsService.swift new file mode 100644 index 0000000000..e23895621e --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Services/ApplicationShortcuts/ApplicationShortcutsService.swift @@ -0,0 +1,94 @@ +import shared +import UIKit + +protocol ApplicationShortcutsServiceProtocol: AnyObject { + func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool + func handleLaunchOptions(_ launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool +} + +final class ApplicationShortcutsService: ApplicationShortcutsServiceProtocol { + private lazy var applicationShortcutsInteractor: ApplicationShortcutsInteractor = + AppGraphBridge.sharedAppGraph.buildApplicationShortcutsDataComponent().applicationShortcutsInteractor + + private lazy var analyticInteractor = AnalyticInteractor.default + + private var sendEmailFeedbackController: SendEmailFeedbackController? + + // MARK: Protocol Conforming + + func handleLaunchOptions(_ launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + if let shortcutItem = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem { + _ = handleShortcutItem(shortcutItem) + return true + } + return false + } + + func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool { + let shortcutType = shortcutItem.type + + analyticInteractor.logEvent( + event: ApplicationShortcutItemClickedHyperskillAnalyticEvent(shortcutItemIdentifier: shortcutType) + ) + + guard let shortcutIdentifier = ApplicationShortcutIdentifier(fullIdentifier: shortcutType) else { + #if DEBUG + print("ApplicationShortcutsService: Did receive unknown shortcut identifier: \(shortcutType)") + #endif + return false + } + + DispatchQueue.main.async { + self.performAction(for: shortcutIdentifier) + } + + return true + } + + // MARK: Private API + + private func performAction(for shortcutIdentifier: ApplicationShortcutIdentifier) { + switch shortcutIdentifier { + case .sendFeedback: + performSendFeedback() + } + } + + private func performSendFeedback() { + applicationShortcutsInteractor.getSendFeedbackEmailData { [weak self] feedbackEmailData, error in + if let error { + #if DEBUG + print("ApplicationShortcutsService: SendFeedback, failed get email data: \(error)") + #endif + return + } + + guard let feedbackEmailData else { + #if DEBUG + print("ApplicationShortcutsService: SendFeedback, no email data") + #endif + return + } + + assert(Thread.current.isMainThread) + + guard let currentPresentedViewController = SourcelessRouter().currentPresentedViewController() else { + #if DEBUG + print("ApplicationShortcutsService: SendFeedback, no current presented view controller") + #endif + return + } + + let sendEmailFeedbackController = SendEmailFeedbackController() + sendEmailFeedbackController.onDidFinish = { [weak self] in + self?.sendEmailFeedbackController = nil + } + self?.sendEmailFeedbackController = sendEmailFeedbackController + + sendEmailFeedbackController.sendFeedback( + feedbackEmailData: feedbackEmailData, + presentationController: currentPresentedViewController + ) + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Services/Auth/Social/SDKs/GoogleSocialAuthSDKProvider.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Services/Auth/Social/SDKs/GoogleSocialAuthSDKProvider.swift index 501bea2bc2..2eb64c3218 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Services/Auth/Social/SDKs/GoogleSocialAuthSDKProvider.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Services/Auth/Social/SDKs/GoogleSocialAuthSDKProvider.swift @@ -44,7 +44,7 @@ final class GoogleSocialAuthSDKProvider: SocialAuthSDKProvider { ), presenting: currentPresentedViewController ) { user, error in - if let error = error { + if let error { #if DEBUG print("GoogleSocialAuthSDKProvider :: error = \(error.localizedDescription)") #endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Systems/AppPowerModeObserver.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Systems/AppPowerModeObserver.swift index 81b9e8467b..c4e9fd603d 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Systems/AppPowerModeObserver.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Systems/AppPowerModeObserver.swift @@ -17,7 +17,7 @@ final class AppPowerModeObserver { private func updateProgressHUDHapticsEnabled() { // swiftlint:disable:next unowned_variable_capture DispatchQueue.main.async { [unowned self] in - ProgressHUD.setHapticsEnabled(!self.isLowPowerModeEnabled) + ProgressHUD.setHapticsEnabled(!isLowPowerModeEnabled) } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/BadgeView/BadgeView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/BadgeView/BadgeView.swift index a62d44f77d..2672743ba4 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/BadgeView/BadgeView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/BadgeView/BadgeView.swift @@ -2,9 +2,10 @@ import SwiftUI extension BadgeView { struct Appearance { - let cornerRadius: CGFloat = 4 + var cornerRadius: CGFloat = 4 + var insets = LayoutInsets(horizontal: 8, vertical: 4) - let insets = LayoutInsets(horizontal: 8, vertical: 4) + var font = Font.caption } } @@ -17,7 +18,7 @@ struct BadgeView: View { var body: some View { Text(text) - .font(.caption) + .font(appearance.font) .foregroundColor(style.foregroundColor) .padding(appearance.insets.edgeInsets) .background(style.backgroundColor) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/CheckboxButton.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/CheckboxButton.swift index de5c1e572e..9429baad89 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/CheckboxButton.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/CheckboxButton.swift @@ -18,7 +18,7 @@ extension CheckboxButton { struct CheckboxButton: View { private(set) var appearance = Appearance() - @Binding var isSelected: Bool + let isSelected: Bool var onClick: (() -> Void)? @@ -57,16 +57,15 @@ struct CheckboxButton: View { } } -struct CheckboxButton_Previews: PreviewProvider { - static var previews: some View { - Group { - CheckboxButton(isSelected: .constant(true)) - .frame(width: 50, height: 50) +#if DEBUG +#Preview { + VStack { + CheckboxButton(isSelected: true) + .frame(width: 50, height: 50) - CheckboxButton(isSelected: .constant(false)) - .frame(width: 50, height: 50) - } - .previewLayout(.sizeThatFits) - .padding() + CheckboxButton(isSelected: false) + .frame(width: 50, height: 50) } + .padding() } +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/Introspect/PullToRefresh.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/Introspect/PullToRefresh.swift index 13d80878c9..31ad07ea0f 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/Introspect/PullToRefresh.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/Introspect/PullToRefresh.swift @@ -24,11 +24,11 @@ private struct PullToRefresh: UIViewRepresentable { } func updateUIView(_ uiView: UIKitIntrospectionView, context: Context) { - /// When `updateUiView` is called after creating the Introspection view, it is not yet in the UIKit hierarchy. - /// At this point, `introspectionView.superview.superview` is nil and we can't access the target UIKit view. - /// To workaround this, we wait until the runloop is done inserting the introspection view in the hierarchy, then run the selector. - /// Finding the target view fails silently if the selector yield no result. This happens when `updateUIView` - /// gets called when the introspection view gets removed from the hierarchy. + // When `updateUiView` is called after creating the Introspection view, it is not yet in the UIKit hierarchy. + // At this point, `introspectionView.superview.superview` is nil and we can't access the target UIKit view. + // To workaround this, we wait until the runloop is done inserting the introspection view in the hierarchy, then run the selector. + // Finding the target view fails silently if the selector yield no result. This happens when `updateUIView` + // gets called when the introspection view gets removed from the hierarchy. let coordinator = context.coordinator if context.coordinator.refreshControl == nil { uiView.moveToWindowHandler = { [weak uiView, weak coordinator] in diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/OffsetObservingScrollView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/OffsetObservingScrollView.swift new file mode 100644 index 0000000000..e4bf932d39 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/OffsetObservingScrollView.swift @@ -0,0 +1,166 @@ +import SwiftUI + +// https://www.swiftbysundell.com/articles/observing-swiftui-scrollview-content-offset/ + +// MARK: - PositionObservingView - + +/// View that observes its position within a given coordinate space, and assigns that position to the specified Binding. +private struct PositionObservingView: View { + var coordinateSpace: CoordinateSpace + + @Binding var position: CGPoint + + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .background( + GeometryReader { geometry in + Color.clear.preference( + key: PreferenceKey.self, + value: geometry.frame(in: coordinateSpace).origin + ) + } + ) + .onPreferenceChange(PreferenceKey.self) { position in + self.position = position + } + } +} + +private extension PositionObservingView { + enum PreferenceKey: SwiftUI.PreferenceKey { + static var defaultValue: CGPoint { .zero } + + static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { + // No-op + } + } +} + +// MARK: - OffsetObservingScrollView - + +/// Specialized scroll view that observes its content offset (scroll position) and assigns it to the specified Binding. +struct OffsetObservingScrollView: View { + var axes: Axis.Set = [.vertical] + + var showsIndicators = true + + @Binding var offset: CGPoint + + @ViewBuilder var content: () -> Content + // The name of our coordinate space doesn't have to be + // stable between view updates (it just needs to be consistent within this view), + // so we'll simply use a plain UUID for it: + private let coordinateSpaceName = UUID() + + var body: some View { + ScrollView(axes, showsIndicators: showsIndicators) { + PositionObservingView( + coordinateSpace: .named(coordinateSpaceName), + position: Binding( + get: { offset }, + set: { newOffset in + offset = CGPoint( + x: -newOffset.x, + y: -newOffset.y + ) + } + ), + content: content + ) + } + .coordinateSpace(name: coordinateSpaceName) + } +} + +// MARK: - Preview - + +#if DEBUG +/// View that renders scrollable content beneath a header that +/// automatically collapses when the user scrolls down. +@available(iOS 15.0, *) +struct ContentView: View { + var title: String + var headerGradient: Gradient + @ViewBuilder var content: () -> Content + + private let headerHeight = (collapsed: 50.0, expanded: 150.0) + @State private var scrollOffset = CGPoint() + + var body: some View { + GeometryReader { geometry in + OffsetObservingScrollView(offset: $scrollOffset) { + VStack(spacing: 0) { + makeHeaderText(collapsed: false) + content() + } + } + .overlay(alignment: .top) { + makeHeaderText(collapsed: true) + .background(alignment: .top) { + headerLinearGradient.ignoresSafeArea() + } + .opacity(collapsedHeaderOpacity) + } + .background(alignment: .top) { + // We attach the expanded header's background to the scroll + // view itself, so that we can make it expand into both the + // safe area, as well as any negative scroll offset area: + headerLinearGradient + .frame(height: max(0, headerHeight.expanded - scrollOffset.y) + geometry.safeAreaInsets.top) + .ignoresSafeArea() + } + } + } +} + +@available(iOS 15.0, *) +private extension ContentView { + var collapsedHeaderOpacity: CGFloat { + let minOpacityOffset = headerHeight.expanded / 2 + let maxOpacityOffset = headerHeight.expanded - headerHeight.collapsed + + guard scrollOffset.y > minOpacityOffset else { + return 0 + } + guard scrollOffset.y < maxOpacityOffset else { + return 1 + } + + let opacityOffsetRange = maxOpacityOffset - minOpacityOffset + return (scrollOffset.y - minOpacityOffset) / opacityOffsetRange + } + + var headerLinearGradient: LinearGradient { + LinearGradient( + gradient: headerGradient, + startPoint: .top, + endPoint: .bottom + ) + } + + func makeHeaderText(collapsed: Bool) -> some View { + Text(title) + .font(collapsed ? .body : .title) + .lineLimit(1) + .padding() + .frame(height: collapsed ? headerHeight.collapsed : headerHeight.expanded) + .frame(maxWidth: .infinity) + .foregroundColor(.white) + .accessibilityHeading(.h1) + .accessibilityHidden(collapsed) + } +} + +@available(iOS 15.0, *) +#Preview { + ContentView( + title: "Test content offset", + headerGradient: Gradient(colors: [.yellow, .indigo]), + content: { + Text("Test") + } + ) +} +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/RadioButton.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/RadioButton.swift index 7546311f2e..473520ecba 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/RadioButton.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/RadioButton.swift @@ -16,7 +16,7 @@ extension RadioButton { struct RadioButton: View { private(set) var appearance = Appearance() - @Binding var isSelected: Bool + let isSelected: Bool var onClick: (() -> Void)? @@ -48,16 +48,15 @@ struct RadioButton: View { } } -struct RadioButton_Previews: PreviewProvider { - static var previews: some View { - Group { - RadioButton(isSelected: .constant(true)) - .frame(width: 24, height: 24) +#if DEBUG +#Preview { + VStack { + RadioButton(isSelected: true) + .frame(width: 24, height: 24) - RadioButton(isSelected: .constant(false)) - .frame(width: 24, height: 24) - } - .previewLayout(.sizeThatFits) - .padding() + RadioButton(isSelected: false) + .frame(width: 24, height: 24) } + .padding() } +#endif diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/Wrappers/TransparentBlurView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/Wrappers/TransparentBlurView.swift new file mode 100644 index 0000000000..fbdf2a3ece --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/SwiftUI/Wrappers/TransparentBlurView.swift @@ -0,0 +1,25 @@ +import SwiftUI +import UIKit + +struct TransparentBlurView: UIViewRepresentable { + func makeUIView(context: Context) -> UIVisualEffectView { + UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) + } + + func updateUIView(_ uiView: UIVisualEffectView, context: Context) { + DispatchQueue.main.async { + guard let backdropLayer = uiView.layer.sublayers?.first else { + return + } + + backdropLayer.filters?.removeAll { filter in + String(describing: filter) != "gaussianBlur" + } + } + } +} + +#Preview { + TransparentBlurView() + .padding() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/UIKitRoundedRectangleButton.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/UIKitRoundedRectangleButton.swift index 20df28715a..fe5da72d1e 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/UIKitRoundedRectangleButton.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/UIKitRoundedRectangleButton.swift @@ -17,7 +17,9 @@ final class UIKitRoundedRectangleButton: UIKitBounceButton { var style: Style { didSet { - updateAppearance() + if style != oldValue { + updateAppearance() + } } } @@ -115,3 +117,16 @@ final class UIKitRoundedRectangleButton: UIKitBounceButton { } } } + +extension UIKitRoundedRectangleButton { + static var primary: UIKitRoundedRectangleButton { + UIKitRoundedRectangleButton( + style: .violet, + appearance: .init( + font: .preferredFont(for: .body, weight: .bold), + intrinsicHeight: 50, + cornerRadius: 13 + ) + ) + } +} diff --git a/iosHyperskillApp/iosHyperskillAppTests/CollectionsTests/LinkedListTests.swift b/iosHyperskillApp/iosHyperskillAppTests/CollectionsTests/LinkedListTests.swift index c5a9a25926..c07957708a 100644 --- a/iosHyperskillApp/iosHyperskillAppTests/CollectionsTests/LinkedListTests.swift +++ b/iosHyperskillApp/iosHyperskillAppTests/CollectionsTests/LinkedListTests.swift @@ -147,3 +147,4 @@ class LinkedListTest: XCTestCase { XCTAssertNil(list.tail) } } +// swiftlint:enable force_unwrapping diff --git a/iosHyperskillApp/iosHyperskillAppTests/ExtensionsTests/URLExtensionsTests.swift b/iosHyperskillApp/iosHyperskillAppTests/ExtensionsTests/URLExtensionsTests.swift index 2ba85ce929..3ef97a6cdc 100644 --- a/iosHyperskillApp/iosHyperskillAppTests/ExtensionsTests/URLExtensionsTests.swift +++ b/iosHyperskillApp/iosHyperskillAppTests/ExtensionsTests/URLExtensionsTests.swift @@ -29,3 +29,4 @@ class URLExtensionsTests: XCTestCase { XCTAssertNil(otherResult) } } +// swiftlint:enable force_unwrapping diff --git a/iosHyperskillApp/iosHyperskillAppTests/Info.plist b/iosHyperskillApp/iosHyperskillAppTests/Info.plist index 2e6d755a3d..aaf6aeadc0 100644 --- a/iosHyperskillApp/iosHyperskillAppTests/Info.plist +++ b/iosHyperskillApp/iosHyperskillAppTests/Info.plist @@ -13,8 +13,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.49.1 + 1.50 CFBundleVersion - 306 + 349 diff --git a/iosHyperskillApp/iosHyperskillAppTests/IntrospectTests/IntrospectScrollViewTests.swift b/iosHyperskillApp/iosHyperskillAppTests/IntrospectTests/IntrospectScrollViewTests.swift index 40e4b72934..678d3678e4 100644 --- a/iosHyperskillApp/iosHyperskillAppTests/IntrospectTests/IntrospectScrollViewTests.swift +++ b/iosHyperskillApp/iosHyperskillAppTests/IntrospectTests/IntrospectScrollViewTests.swift @@ -14,7 +14,7 @@ final class IntrospectScrollViewTests: XCTestCase { EmptyView() } .introspectScrollView { _ in - self.spy() + spy() } } } diff --git a/iosHyperskillApp/iosHyperskillAppTests/IntrospectTests/IntrospectViewControllerTests.swift b/iosHyperskillApp/iosHyperskillAppTests/IntrospectTests/IntrospectViewControllerTests.swift index 667f80fbb2..845c04e7de 100644 --- a/iosHyperskillApp/iosHyperskillAppTests/IntrospectTests/IntrospectViewControllerTests.swift +++ b/iosHyperskillApp/iosHyperskillAppTests/IntrospectTests/IntrospectViewControllerTests.swift @@ -14,7 +14,7 @@ final class IntrospectViewControllerTests: XCTestCase { } } .introspectViewController { _ in - self.spy() + spy() } } } @@ -29,7 +29,7 @@ final class IntrospectViewControllerTests: XCTestCase { } } .introspectHostingController { (_: UIHostingController) in - self.spy() + spy() } } } diff --git a/iosHyperskillApp/iosHyperskillAppUITests/Info.plist b/iosHyperskillApp/iosHyperskillAppUITests/Info.plist index fa905e3a84..f7dad4f2a2 100644 --- a/iosHyperskillApp/iosHyperskillAppUITests/Info.plist +++ b/iosHyperskillApp/iosHyperskillAppUITests/Info.plist @@ -13,8 +13,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.49.1 + 1.50 CFBundleVersion - 306 + 349 diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 12c0ccf9f8..29a40158c8 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.api.dsl.LibraryBuildType import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.BOOLEAN import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING import java.time.Year @@ -7,6 +8,7 @@ import org.jetbrains.dokka.base.DokkaBaseConfiguration import org.jetbrains.dokka.gradle.DokkaTask import org.jetbrains.kotlin.gradle.plugin.mpp.Framework import org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink +import org.jetbrains.kotlin.konan.properties.Properties import org.jetbrains.kotlin.konan.properties.loadProperties import org.jetbrains.kotlin.konan.properties.propertyString @@ -60,7 +62,7 @@ kotlin { implementation(libs.kit.model) implementation(libs.kotlin.datetime) implementation(libs.kit.presentation.reduxCoroutines) - implementation(libs.kermit.common) + implementation(libs.kermit) api(libs.kit.presentation.redux) api(libs.mokoResources.main) @@ -90,6 +92,8 @@ kotlin { implementation(libs.android.parcelable) implementation(project.dependencies.platform(libs.firebase.bom)) implementation(libs.firebase.messaging) + implementation(libs.revenuecat) + implementation(libs.kermit) } } val androidUnitTest by getting { @@ -153,6 +157,26 @@ android { sourceSets { getByName("main").java.srcDirs("build/generated/moko/androidMain/src") } + + buildTypes { + fun applyFlavorConfigsFromFile(libraryBuildType: LibraryBuildType) { + if (SystemProperties.isCI() && !SystemProperties.isGitCryptUnlocked()) return + val properties: Properties = + loadProperties("${project.rootDir}/shared/src/androidMain/keys/revenuecat.properties") + properties.keys.forEach { name -> + name as String + libraryBuildType.buildConfigField( + type = "String", + name = name, + value = requireNotNull(properties.propertyString(name)) + ) + } + } + + all { + applyFlavorConfigsFromFile(this) + } + } } buildkonfig { diff --git a/shared/src/androidMain/keys/revenuecat.properties b/shared/src/androidMain/keys/revenuecat.properties new file mode 100644 index 0000000000..697732a372 Binary files /dev/null and b/shared/src/androidMain/keys/revenuecat.properties differ diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/core/domain/platform/Platform.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/core/domain/platform/Platform.kt index 51c02f56aa..5994210bca 100644 --- a/shared/src/androidMain/kotlin/org/hyperskill/app/core/domain/platform/Platform.kt +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/core/domain/platform/Platform.kt @@ -12,4 +12,6 @@ actual class Platform actual constructor() { actual val feedbackName: String = "Android" actual val appNameResource: StringResource = SharedResources.strings.android_app_name + + actual val isSubscriptionPurchaseEnabled: Boolean = true } \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/core/injection/CommonAndroidAppGraph.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/core/injection/CommonAndroidAppGraph.kt index 0fa2cfac4d..cf1ea58bf1 100644 --- a/shared/src/androidMain/kotlin/org/hyperskill/app/core/injection/CommonAndroidAppGraph.kt +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/core/injection/CommonAndroidAppGraph.kt @@ -1,6 +1,6 @@ package org.hyperskill.app.core.injection -import android.content.Context +import android.app.Application import org.hyperskill.app.auth.injection.AuthCredentialsComponent import org.hyperskill.app.auth.injection.AuthSocialComponent import org.hyperskill.app.auth.injection.PlatformAuthCredentialsComponent @@ -14,7 +14,10 @@ import org.hyperskill.app.home.injection.PlatformHomeComponent import org.hyperskill.app.interview_preparation_onboarding.injection.PlatformInterviewPreparationOnboardingComponent import org.hyperskill.app.leaderboard.injection.PlatformLeaderboardComponent import org.hyperskill.app.main.injection.PlatformMainComponent +import org.hyperskill.app.manage_subscription.injection.PlatformManageSubscriptionComponent import org.hyperskill.app.notifications_onboarding.injection.PlatformNotificationsOnboardingComponent +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.paywall.injection.PlatformPaywallComponent import org.hyperskill.app.play_services.injection.PlayServicesCheckerComponent import org.hyperskill.app.profile.injection.PlatformProfileComponent import org.hyperskill.app.profile.injection.ProfileComponent @@ -25,6 +28,7 @@ import org.hyperskill.app.project_selection.details.injection.PlatformProjectSel import org.hyperskill.app.project_selection.details.injection.ProjectSelectionDetailsParams import org.hyperskill.app.project_selection.list.injection.PlatformProjectSelectionListComponent import org.hyperskill.app.project_selection.list.injection.ProjectSelectionListParams +import org.hyperskill.app.request_review.injection.PlatformRequestReviewComponent import org.hyperskill.app.search.injection.PlatformSearchComponent import org.hyperskill.app.stage_implementation.injection.PlatformStageImplementationComponent import org.hyperskill.app.step.domain.model.StepRoute @@ -38,11 +42,12 @@ import org.hyperskill.app.track_selection.details.injection.PlatformTrackSelecti import org.hyperskill.app.track_selection.details.injection.TrackSelectionDetailsParams import org.hyperskill.app.track_selection.list.injection.PlatformTrackSelectionListComponent import org.hyperskill.app.track_selection.list.injection.TrackSelectionListParams +import org.hyperskill.app.users_questionnaire.onboarding.injection.PlatformUsersQuestionnaireOnboardingComponent import org.hyperskill.app.welcome.injection.PlatformWelcomeComponent import org.hyperskill.app.welcome.injection.WelcomeComponent interface CommonAndroidAppGraph : AppGraph { - val context: Context + val application: Application val platformMainComponent: PlatformMainComponent @@ -105,4 +110,14 @@ interface CommonAndroidAppGraph : AppGraph { fun buildPlatformInterviewPreparationOnboardingComponent( stepRoute: StepRoute ): PlatformInterviewPreparationOnboardingComponent + + fun buildPlatformRequestReviewComponent( + stepRoute: StepRoute + ): PlatformRequestReviewComponent + + fun buildPlatformUsersQuestionnaireOnboardingComponent(): PlatformUsersQuestionnaireOnboardingComponent + + fun buildPlatformPaywallComponent(paywallTransitionSource: PaywallTransitionSource): PlatformPaywallComponent + + fun buildPlatformManageSubscriptionComponent(): PlatformManageSubscriptionComponent } \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/core/injection/CommonAndroidAppGraphImpl.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/core/injection/CommonAndroidAppGraphImpl.kt index 12e563e7b7..655a6c0278 100644 --- a/shared/src/androidMain/kotlin/org/hyperskill/app/core/injection/CommonAndroidAppGraphImpl.kt +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/core/injection/CommonAndroidAppGraphImpl.kt @@ -1,5 +1,6 @@ package org.hyperskill.app.core.injection +import org.hyperskill.app.BuildConfig import org.hyperskill.app.auth.injection.AuthCredentialsComponent import org.hyperskill.app.auth.injection.AuthSocialComponent import org.hyperskill.app.auth.injection.PlatformAuthCredentialsComponent @@ -20,10 +21,15 @@ import org.hyperskill.app.interview_preparation_onboarding.injection.PlatformInt import org.hyperskill.app.interview_preparation_onboarding.injection.PlatformInterviewPreparationOnboardingComponentImpl import org.hyperskill.app.leaderboard.injection.PlatformLeaderboardComponent import org.hyperskill.app.leaderboard.injection.PlatformLeaderboardComponentImpl +import org.hyperskill.app.manage_subscription.injection.PlatformManageSubscriptionComponent +import org.hyperskill.app.manage_subscription.injection.PlatformManageSubscriptionComponentImpl import org.hyperskill.app.notification.remote.injection.AndroidPlatformPushNotificationsPlatformDataComponent import org.hyperskill.app.notification.remote.injection.PlatformPushNotificationsDataComponent import org.hyperskill.app.notifications_onboarding.injection.PlatformNotificationsOnboardingComponent import org.hyperskill.app.notifications_onboarding.injection.PlatformNotificationsOnboardingComponentImpl +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.paywall.injection.PlatformPaywallComponent +import org.hyperskill.app.paywall.injection.PlatformPaywallComponentImpl import org.hyperskill.app.play_services.injection.PlayServicesCheckerComponent import org.hyperskill.app.play_services.injection.PlayServicesCheckerComponentImpl import org.hyperskill.app.profile.injection.PlatformProfileComponent @@ -40,6 +46,11 @@ import org.hyperskill.app.project_selection.details.injection.ProjectSelectionDe import org.hyperskill.app.project_selection.list.injection.PlatformProjectSelectionListComponent import org.hyperskill.app.project_selection.list.injection.PlatformProjectSelectionListComponentImpl import org.hyperskill.app.project_selection.list.injection.ProjectSelectionListParams +import org.hyperskill.app.purchases.domain.AndroidPurchaseManager +import org.hyperskill.app.purchases.injection.PurchaseComponent +import org.hyperskill.app.purchases.injection.PurchaseComponentImpl +import org.hyperskill.app.request_review.injection.PlatformRequestReviewComponent +import org.hyperskill.app.request_review.injection.PlatformRequestReviewComponentImpl import org.hyperskill.app.search.injection.PlatformSearchComponent import org.hyperskill.app.search.injection.PlatformSearchComponentImpl import org.hyperskill.app.stage_implementation.injection.PlatformStageImplementationComponent @@ -61,12 +72,22 @@ import org.hyperskill.app.track_selection.details.injection.TrackSelectionDetail import org.hyperskill.app.track_selection.list.injection.PlatformTrackSelectionListComponent import org.hyperskill.app.track_selection.list.injection.PlatformTrackSelectionListComponentImpl import org.hyperskill.app.track_selection.list.injection.TrackSelectionListParams +import org.hyperskill.app.users_questionnaire.onboarding.injection.PlatformUsersQuestionnaireOnboardingComponent +import org.hyperskill.app.users_questionnaire.onboarding.injection.PlatformUsersQuestionnaireOnboardingComponentImpl import org.hyperskill.app.welcome.injection.PlatformWelcomeComponent import org.hyperskill.app.welcome.injection.PlatformWelcomeComponentImpl import org.hyperskill.app.welcome.injection.WelcomeComponent abstract class CommonAndroidAppGraphImpl : CommonAndroidAppGraph, BaseAppGraph() { + override fun buildPurchaseComponent(): PurchaseComponent = + PurchaseComponentImpl( + AndroidPurchaseManager( + application = application, + isDebugMode = BuildConfig.DEBUG + ) + ) + override fun buildPlatformAuthSocialWebViewComponent(): PlatformAuthSocialWebViewComponent = PlatformAuthSocialWebViewComponentImpl(authSocialComponent = buildAuthSocialComponent()) @@ -198,7 +219,7 @@ abstract class CommonAndroidAppGraphImpl : CommonAndroidAppGraph, BaseAppGraph() PlatformProgressScreenComponentImpl(buildProgressScreenComponent()) override fun buildPlayServicesCheckerComponent(): PlayServicesCheckerComponent = - PlayServicesCheckerComponentImpl(context, sentryComponent) + PlayServicesCheckerComponentImpl(application, sentryComponent) override fun buildPlatformPushNotificationsDataComponent(): PlatformPushNotificationsDataComponent = AndroidPlatformPushNotificationsPlatformDataComponent( @@ -235,4 +256,28 @@ abstract class CommonAndroidAppGraphImpl : CommonAndroidAppGraph, BaseAppGraph() interviewPreparationOnboardingComponent = buildInterviewPreparationOnboardingComponent(), stepRoute = stepRoute ) + + override fun buildPlatformRequestReviewComponent( + stepRoute: StepRoute + ): PlatformRequestReviewComponent = + PlatformRequestReviewComponentImpl( + requestReviewComponent = buildRequestReviewModalComponent(stepRoute) + ) + + override fun buildPlatformUsersQuestionnaireOnboardingComponent(): PlatformUsersQuestionnaireOnboardingComponent = + PlatformUsersQuestionnaireOnboardingComponentImpl( + usersQuestionnaireOnboardingComponent = buildUsersQuestionnaireOnboardingComponent() + ) + + override fun buildPlatformPaywallComponent( + paywallTransitionSource: PaywallTransitionSource + ): PlatformPaywallComponent = + PlatformPaywallComponentImpl( + paywallComponent = buildPaywallComponent(paywallTransitionSource) + ) + + override fun buildPlatformManageSubscriptionComponent(): PlatformManageSubscriptionComponent = + PlatformManageSubscriptionComponentImpl( + manageSubscriptionComponent = buildManageSubscriptionComponent() + ) } \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/manage_subscription/injection/PlatformManageSubscriptionComponent.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/manage_subscription/injection/PlatformManageSubscriptionComponent.kt new file mode 100644 index 0000000000..821b5b1a08 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/manage_subscription/injection/PlatformManageSubscriptionComponent.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.manage_subscription.injection + +import org.hyperskill.app.core.injection.ReduxViewModelFactory + +interface PlatformManageSubscriptionComponent { + val reduxViewModelFactory: ReduxViewModelFactory +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/manage_subscription/injection/PlatformManageSubscriptionComponentImpl.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/manage_subscription/injection/PlatformManageSubscriptionComponentImpl.kt new file mode 100644 index 0000000000..bd0c704647 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/manage_subscription/injection/PlatformManageSubscriptionComponentImpl.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.manage_subscription.injection + +import org.hyperskill.app.core.flowredux.presentation.wrapWithFlowView +import org.hyperskill.app.core.injection.ReduxViewModelFactory +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionViewModel + +class PlatformManageSubscriptionComponentImpl( + private val manageSubscriptionComponent: ManageSubscriptionComponent +) : PlatformManageSubscriptionComponent { + override val reduxViewModelFactory: ReduxViewModelFactory + get() = ReduxViewModelFactory( + mapOf( + ManageSubscriptionViewModel::class.java to { + ManageSubscriptionViewModel( + manageSubscriptionComponent.manageSubscriptionFeature.wrapWithFlowView() + ) + } + ) + ) +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionViewModel.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionViewModel.kt new file mode 100644 index 0000000000..133cb89c24 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionViewModel.kt @@ -0,0 +1,24 @@ +package org.hyperskill.app.manage_subscription.presentation + +import org.hyperskill.app.core.flowredux.presentation.FlowView +import org.hyperskill.app.core.flowredux.presentation.ReduxFlowViewModel +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Action.ViewAction +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Message +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.ViewState + +class ManageSubscriptionViewModel( + viewContainer: FlowView +) : ReduxFlowViewModel(viewContainer) { + + init { + onNewMessage(Message.Initialize) + } + + fun onRetryClick() { + onNewMessage(Message.RetryContentLoading) + } + + fun onActionButtonClick() { + onNewMessage(Message.ActionButtonClicked) + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/injection/PlatformPaywallComponent.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/injection/PlatformPaywallComponent.kt new file mode 100644 index 0000000000..7e899b57db --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/injection/PlatformPaywallComponent.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.paywall.injection + +import org.hyperskill.app.core.injection.ReduxViewModelFactory + +interface PlatformPaywallComponent { + val reduxViewModelFactory: ReduxViewModelFactory +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/injection/PlatformPaywallComponentImpl.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/injection/PlatformPaywallComponentImpl.kt new file mode 100644 index 0000000000..a9f876f657 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/injection/PlatformPaywallComponentImpl.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.paywall.injection + +import org.hyperskill.app.core.flowredux.presentation.wrapWithFlowView +import org.hyperskill.app.core.injection.ReduxViewModelFactory +import org.hyperskill.app.paywall.presentation.PaywallViewModel + +class PlatformPaywallComponentImpl( + private val paywallComponent: PaywallComponent +) : PlatformPaywallComponent { + override val reduxViewModelFactory: ReduxViewModelFactory + get() = ReduxViewModelFactory( + mapOf( + PaywallViewModel::class.java to { + PaywallViewModel( + paywallComponent.paywallFeature.wrapWithFlowView() + ) + } + ) + ) +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallViewModel.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallViewModel.kt new file mode 100644 index 0000000000..eaf0f4d5d9 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallViewModel.kt @@ -0,0 +1,38 @@ +package org.hyperskill.app.paywall.presentation + +import android.app.Activity +import org.hyperskill.app.core.flowredux.presentation.FlowView +import org.hyperskill.app.core.flowredux.presentation.ReduxFlowViewModel +import org.hyperskill.app.paywall.presentation.PaywallFeature.Action.ViewAction +import org.hyperskill.app.paywall.presentation.PaywallFeature.Message +import org.hyperskill.app.paywall.presentation.PaywallFeature.ViewState +import org.hyperskill.app.purchases.domain.model.AndroidPurchaseParams + +class PaywallViewModel( + reduxViewContainer: FlowView +) : ReduxFlowViewModel(reduxViewContainer) { + + init { + onNewMessage(Message.Initialize) + } + + fun onBuySubscriptionClick(activity: Activity) { + onNewMessage( + Message.BuySubscriptionClicked( + AndroidPurchaseParams(activity) + ) + ) + } + + fun onContinueWithLimitsClick() { + onNewMessage(Message.ContinueWithLimitsClicked) + } + + fun onRetryLoadingClicked() { + onNewMessage(Message.RetryContentLoading) + } + + fun onTermsOfServiceClick() { + onNewMessage(Message.ClickedTermsOfServiceAndPrivacyPolicy) + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/profile/presentation/ProfileSettingsViewModel.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/profile/presentation/ProfileSettingsViewModel.kt index 170d14658d..8404ae75d5 100644 --- a/shared/src/androidMain/kotlin/org/hyperskill/app/profile/presentation/ProfileSettingsViewModel.kt +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/profile/presentation/ProfileSettingsViewModel.kt @@ -1,15 +1,11 @@ package org.hyperskill.app.profile.presentation -import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Action.ViewAction +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Message +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.ViewState import ru.nobird.android.view.redux.viewmodel.ReduxViewModel import ru.nobird.app.presentation.redux.container.ReduxViewContainer class ProfileSettingsViewModel( - reduxViewContainer: ReduxViewContainer< - ProfileSettingsFeature.State, - ProfileSettingsFeature.Message, - ProfileSettingsFeature.Action.ViewAction> -) : ReduxViewModel< - ProfileSettingsFeature.State, - ProfileSettingsFeature.Message, - ProfileSettingsFeature.Action.ViewAction>(reduxViewContainer) \ No newline at end of file + reduxViewContainer: ReduxViewContainer +) : ReduxViewModel(reduxViewContainer) \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/AndroidPurchaseManager.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/AndroidPurchaseManager.kt new file mode 100644 index 0000000000..700fff08ce --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/AndroidPurchaseManager.kt @@ -0,0 +1,114 @@ +package org.hyperskill.app.purchases.domain + +import android.app.Activity +import android.app.Application +import com.revenuecat.purchases.LogLevel +import com.revenuecat.purchases.PurchaseParams +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesConfiguration +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.PurchasesTransactionException +import com.revenuecat.purchases.awaitCustomerInfo +import com.revenuecat.purchases.awaitGetProducts +import com.revenuecat.purchases.awaitLogIn +import com.revenuecat.purchases.awaitPurchase +import com.revenuecat.purchases.models.StoreProduct +import org.hyperskill.app.BuildConfig +import org.hyperskill.app.purchases.domain.model.AndroidPurchaseParams +import org.hyperskill.app.purchases.domain.model.PlatformPurchaseParams +import org.hyperskill.app.purchases.domain.model.PurchaseManager +import org.hyperskill.app.purchases.domain.model.PurchaseResult + +class AndroidPurchaseManager( + private val application: Application, + private val isDebugMode: Boolean +) : PurchaseManager { + + override fun isConfigured(): Boolean = + Purchases.isConfigured + + override fun configure(userId: Long) { + Purchases.logLevel = if (isDebugMode) LogLevel.DEBUG else LogLevel.INFO + Purchases.configure( + PurchasesConfiguration + .Builder( + context = application, + apiKey = BuildConfig.REVENUE_CAT_GOOGLE_API_KEY + ) + .appUserID(userId.toString()) + .build() + ) + } + + override suspend fun login(userId: Long): Result = + kotlin.runCatching { + Purchases.sharedInstance.awaitLogIn(userId.toString()) + } + + override suspend fun purchase( + productId: String, + platformPurchaseParams: PlatformPurchaseParams + ): Result = + runCatching { + val product = try { + fetchProduct(productId) ?: return@runCatching PurchaseResult.Error.NoProductFound(productId) + } catch (e: PurchasesException) { + return@runCatching mapProductFetchException(productId, e) + } + val activity = (platformPurchaseParams as AndroidPurchaseParams).activity + purchase(activity, product) + } + + private fun mapProductFetchException(productId: String, e: PurchasesException): PurchaseResult = + PurchaseResult.Error.ErrorWhileFetchingProduct( + productId = productId, + originMessage = e.message, + underlyingErrorMessage = e.error.underlyingErrorMessage + ) + + private suspend fun purchase(activity: Activity, product: StoreProduct): PurchaseResult = + try { + val purchaseResult = Purchases.sharedInstance.awaitPurchase( + PurchaseParams.Builder(activity, product).build() + ) + PurchaseResult.Succeed( + orderId = purchaseResult.storeTransaction.orderId, + productIds = purchaseResult.storeTransaction.productIds + ) + } catch (e: PurchasesTransactionException) { + mapException(e) + } + + private fun mapException(e: PurchasesTransactionException): PurchaseResult { + if (e.userCancelled) return PurchaseResult.CancelledByUser + return when (e.error.code) { + PurchasesErrorCode.ReceiptAlreadyInUseError -> + PurchaseResult.Error.ReceiptAlreadyInUseError(e.message, e.underlyingErrorMessage) + PurchasesErrorCode.PaymentPendingError -> + PurchaseResult.Error.PaymentPendingError(e.message, e.underlyingErrorMessage) + PurchasesErrorCode.ProductAlreadyPurchasedError -> + PurchaseResult.Error.ProductAlreadyPurchasedError(e.message, e.underlyingErrorMessage) + PurchasesErrorCode.PurchaseNotAllowedError -> + PurchaseResult.Error.PurchaseNotAllowedError(e.message, e.underlyingErrorMessage) + PurchasesErrorCode.StoreProblemError -> + PurchaseResult.Error.StoreProblemError(e.message, e.underlyingErrorMessage) + else -> PurchaseResult.Error.OtherError(e.message, e.underlyingErrorMessage) + } + } + + override suspend fun getManagementUrl(): Result = + kotlin.runCatching { + Purchases.sharedInstance.awaitCustomerInfo().managementURL?.toString() + } + + override suspend fun getFormattedProductPrice(productId: String): Result = + kotlin.runCatching { + fetchProduct(productId)?.price?.formatted + } + + private suspend fun fetchProduct(productId: String): StoreProduct? = + Purchases.sharedInstance + .awaitGetProducts(listOf(productId)) + .firstOrNull() +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/model/AndroidPurchaseParams.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/model/AndroidPurchaseParams.kt new file mode 100644 index 0000000000..30fd1cb3c2 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/purchases/domain/model/AndroidPurchaseParams.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.purchases.domain.model + +import android.app.Activity + +data class AndroidPurchaseParams( + val activity: Activity +) : PlatformPurchaseParams \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/request_review/injection/PlatformRequestReviewComponent.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/request_review/injection/PlatformRequestReviewComponent.kt new file mode 100644 index 0000000000..92c2eed993 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/request_review/injection/PlatformRequestReviewComponent.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.request_review.injection + +import org.hyperskill.app.core.injection.ReduxViewModelFactory + +interface PlatformRequestReviewComponent { + val reduxViewModelFactory: ReduxViewModelFactory +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/request_review/injection/PlatformRequestReviewComponentImpl.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/request_review/injection/PlatformRequestReviewComponentImpl.kt new file mode 100644 index 0000000000..c0c14bf83e --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/request_review/injection/PlatformRequestReviewComponentImpl.kt @@ -0,0 +1,21 @@ +package org.hyperskill.app.request_review.injection + +import org.hyperskill.app.core.flowredux.presentation.wrapWithFlowView +import org.hyperskill.app.core.injection.ReduxViewModelFactory +import org.hyperskill.app.request_review.modal.injection.RequestReviewModalComponent +import org.hyperskill.app.request_review.presentation.RequestReviewModalViewModel + +internal class PlatformRequestReviewComponentImpl( + private val requestReviewComponent: RequestReviewModalComponent +) : PlatformRequestReviewComponent { + override val reduxViewModelFactory: ReduxViewModelFactory + get() = ReduxViewModelFactory( + mapOf( + RequestReviewModalViewModel::class.java to { + RequestReviewModalViewModel( + requestReviewComponent.requestReviewModalFeature.wrapWithFlowView() + ) + } + ) + ) +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/request_review/presentation/RequestReviewModalViewModel.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/request_review/presentation/RequestReviewModalViewModel.kt new file mode 100644 index 0000000000..5bcbbc230c --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/request_review/presentation/RequestReviewModalViewModel.kt @@ -0,0 +1,27 @@ +package org.hyperskill.app.request_review.presentation + +import org.hyperskill.app.core.flowredux.presentation.FlowView +import org.hyperskill.app.core.flowredux.presentation.ReduxFlowViewModel +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.Action.ViewAction +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.Message +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.ViewState + +class RequestReviewModalViewModel( + viewContainer: FlowView +) : ReduxFlowViewModel(viewContainer) { + fun onPositiveButtonClick() { + onNewMessage(Message.PositiveButtonClicked) + } + + fun onNegativeButtonClick() { + onNewMessage(Message.NegativeButtonClicked) + } + + fun onShownEvent() { + onNewMessage(Message.ShownEventMessage) + } + + fun onHiddenEvent() { + onNewMessage(Message.HiddenEventMessage) + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/study_plan/presentation/StudyPlanScreenViewModel.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/study_plan/presentation/StudyPlanScreenViewModel.kt index 77f43c5be0..4f0829d9fb 100644 --- a/shared/src/androidMain/kotlin/org/hyperskill/app/study_plan/presentation/StudyPlanScreenViewModel.kt +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/study_plan/presentation/StudyPlanScreenViewModel.kt @@ -1,6 +1,7 @@ package org.hyperskill.app.study_plan.presentation import org.hyperskill.app.study_plan.screen.presentation.StudyPlanScreenFeature +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature import ru.nobird.android.view.redux.viewmodel.ReduxViewModel import ru.nobird.app.presentation.redux.container.ReduxViewContainer @@ -11,8 +12,13 @@ class StudyPlanScreenViewModel( StudyPlanScreenFeature.ViewState, StudyPlanScreenFeature.Message, StudyPlanScreenFeature.Action.ViewAction>(reduxViewContainer) { + init { onNewMessage(StudyPlanScreenFeature.Message.Initialize) onNewMessage(StudyPlanScreenFeature.Message.ViewedEventMessage) } + + fun onNewMessage(message: UsersQuestionnaireWidgetFeature.Message) { + onNewMessage(StudyPlanScreenFeature.Message.UsersQuestionnaireWidgetMessage(message)) + } } \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/PlatformUsersQuestionnaireOnboardingComponent.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/PlatformUsersQuestionnaireOnboardingComponent.kt new file mode 100644 index 0000000000..d1b6fc8d9b --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/PlatformUsersQuestionnaireOnboardingComponent.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.users_questionnaire.onboarding.injection + +import org.hyperskill.app.core.injection.ReduxViewModelFactory + +interface PlatformUsersQuestionnaireOnboardingComponent { + val reduxViewModelFactory: ReduxViewModelFactory +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/PlatformUsersQuestionnaireOnboardingComponentImpl.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/PlatformUsersQuestionnaireOnboardingComponentImpl.kt new file mode 100644 index 0000000000..1d98590ddd --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/PlatformUsersQuestionnaireOnboardingComponentImpl.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.users_questionnaire.onboarding.injection + +import org.hyperskill.app.core.flowredux.presentation.wrapWithFlowView +import org.hyperskill.app.core.injection.ReduxViewModelFactory +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingViewModel + +internal class PlatformUsersQuestionnaireOnboardingComponentImpl( + private val usersQuestionnaireOnboardingComponent: UsersQuestionnaireOnboardingComponent +) : PlatformUsersQuestionnaireOnboardingComponent { + override val reduxViewModelFactory: ReduxViewModelFactory + get() = ReduxViewModelFactory( + mapOf( + UsersQuestionnaireOnboardingViewModel::class.java to { + UsersQuestionnaireOnboardingViewModel( + usersQuestionnaireOnboardingComponent.usersQuestionnaireOnboardingFeature.wrapWithFlowView() + ) + } + ) + ) +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/presentation/UsersQuestionnaireOnboardingViewModel.kt b/shared/src/androidMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/presentation/UsersQuestionnaireOnboardingViewModel.kt new file mode 100644 index 0000000000..4bd8be2bc4 --- /dev/null +++ b/shared/src/androidMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/presentation/UsersQuestionnaireOnboardingViewModel.kt @@ -0,0 +1,27 @@ +package org.hyperskill.app.users_questionnaire.onboarding.presentation + +import org.hyperskill.app.core.flowredux.presentation.FlowView +import org.hyperskill.app.core.flowredux.presentation.ReduxFlowViewModel +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Action.ViewAction +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Message +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.ViewState + +class UsersQuestionnaireOnboardingViewModel( + viewContainer: FlowView +) : ReduxFlowViewModel(viewContainer) { + fun onChoiceClicked(choice: String) { + onNewMessage(Message.ClickedChoice(choice)) + } + + fun onTextInputChanged(text: String) { + onNewMessage(Message.TextInputValueChanged(text)) + } + + fun onSendButtonClick() { + onNewMessage(Message.SendButtonClicked) + } + + fun onSkipButtonClick() { + onNewMessage(Message.SkipButtonClicked) + } +} \ No newline at end of file diff --git a/shared/src/androidUnitTest/kotlin/org/hyperskill/step_quiz/AndroidStepQuizTest.kt b/shared/src/androidUnitTest/kotlin/org/hyperskill/step_quiz/AndroidStepQuizTest.kt index 50f5cad3b2..78690d5332 100644 --- a/shared/src/androidUnitTest/kotlin/org/hyperskill/step_quiz/AndroidStepQuizTest.kt +++ b/shared/src/androidUnitTest/kotlin/org/hyperskill/step_quiz/AndroidStepQuizTest.kt @@ -77,7 +77,7 @@ class AndroidStepQuizTest { attempt, submissionState, isProblemsLimitReached = false, - problemsLimitReachedModalText = null, + problemsLimitReachedModalData = null, problemsOnboardingFlags = ProblemsOnboardingFlags( isParsonsOnboardingShown = false, isFillBlanksInputModeOnboardingShown = false, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt index f9b0857952..891143a527 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt @@ -21,6 +21,7 @@ enum class HyperskillAnalyticPart(val partName: String) { DAILY_STEP_COMPLETED_MODAL("daily_step_completed_modal"), TOPIC_COMPLETED_MODAL("topic_completed_modal"), PROBLEMS_LIMIT_REACHED_MODAL("problems_limit_reached_modal"), + PROBLEMS_LIMIT_WIDGET("problems_limit_widget"), PARSONS_PROBLEM_ONBOARDING_MODAL("parsons_problem_onboarding_modal"), FILL_BLANKS_INPUT_MODE_ONBOARDING_MODAL("fill_blanks_input_mode_onboarding_modal"), FILL_BLANKS_SELECT_MODE_ONBOARDING_MODAL("fill_blanks_select_mode_onboarding_modal"), @@ -42,5 +43,8 @@ enum class HyperskillAnalyticPart(val partName: String) { SEARCH_RESULTS("search_results"), DAILY_STUDY_REMINDERS_HOUR_INTERVAL_PICKER_MODAL("daily_study_reminders_hour_interval_picker_modal"), INTERVIEW_PREPARATION_WIDGET("interview_preparation_widget"), - INTERVIEW_PREPARATION_COMPLETED_MODAL("interview_preparation_completed_modal") + INTERVIEW_PREPARATION_COMPLETED_MODAL("interview_preparation_completed_modal"), + REQUEST_REVIEW_MODAL("request_review_modal"), + USERS_QUESTIONNAIRE_WIDGET("users_questionnaire_widget"), + UNSUPPORTED_QUIZ_PLACEHOLDER("unsupported_quiz_placeholder") } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticRoute.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticRoute.kt index e57fba4923..ec337bed5d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticRoute.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticRoute.kt @@ -20,6 +20,11 @@ sealed class HyperskillAnalyticRoute { override val path: String get() = "${super.path}/interview-preparation" } + + object UsersQuestionnaire : Onboarding() { + override val path: String + get() = "${super.path}/questionnaire" + } } open class Login : HyperskillAnalyticRoute() { @@ -31,10 +36,6 @@ sealed class HyperskillAnalyticRoute { } } - class Register : HyperskillAnalyticRoute() { - override val path: String = "/register" - } - sealed class Learn : HyperskillAnalyticRoute() { override val path: String = "/learn" @@ -96,16 +97,17 @@ sealed class HyperskillAnalyticRoute { } } - class Track : HyperskillAnalyticRoute() { - override val path: String = "/track" - } - open class Profile : HyperskillAnalyticRoute() { override val path: String = "/profile" - class Settings : Profile() { + open class Settings : Profile() { override val path: String = "${super.path}/settings" + + object ManageSubscription : Settings() { + override val path: String + get() = "${super.path}/manage-subscription" + } } } @@ -113,14 +115,17 @@ sealed class HyperskillAnalyticRoute { override val path: String = "/debug" } - class StudyPlan : HyperskillAnalyticRoute() { - override val path: String = - "/study-plan" + open class StudyPlan : HyperskillAnalyticRoute() { + override val path: String = "/study-plan" + + class UsersQuestionnaireWidget : StudyPlan() { + override val path: String + get() = "${super.path}/users-questionnaire-widget" + } } class Leaderboard : HyperskillAnalyticRoute() { - override val path: String = - "/leaderboard" + override val path: String = "/leaderboard" } open class Tracks : HyperskillAnalyticRoute() { @@ -138,12 +143,36 @@ sealed class HyperskillAnalyticRoute { } class Progress : HyperskillAnalyticRoute() { - override val path: String = - "/progress" + override val path: String = "/progress" } class Search : HyperskillAnalyticRoute() { - override val path: String = - "/search" + override val path: String = "/search" + } + + /** + * Represents a special route when we do not know where the events is occurred (ALTAPPS-1086). + */ + object None : HyperskillAnalyticRoute() { + override val path: String = "None" + } + + /** + * Springboard, or Home Screen is the standard application that manages the home screen of Apple devices. + */ + class IosSpringBoard : HyperskillAnalyticRoute() { + override val path: String = "SpringBoard" + } + + object Paywall : HyperskillAnalyticRoute() { + override val path: String + get() = "/paywall" + } + + /** + * Represents a special route that is used to track the first time the app is launched (ALTAPPS-1139). + */ + internal class AppLaunchFirstTime : HyperskillAnalyticRoute() { + override val path: String = "app-launch-first-time" } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt index c8bc613d8c..66f500ebe9 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt @@ -8,6 +8,7 @@ enum class HyperskillAnalyticTarget(val targetName: String) { PROFILE("profile"), DEBUG("debug"), SEND("send"), + SKIP("skip"), INPUT_OUTPUT_INFO("input_output_info"), STEP_TEXT_DESCRIPTION("step_text_description"), RESET("reset"), @@ -27,6 +28,8 @@ enum class HyperskillAnalyticTarget(val targetName: String) { SEND_FEEDBACK("send_feedback"), DELETE_ACCOUNT("delete_account"), DELETE_ACCOUNT_NOTICE("delete_account_notice"), + RATE_US_IN_APP_STORE("rate_us_in_app_store"), + RATE_US_IN_PLAY_STORE("rate_us_in_play_store"), SIGN_OUT_NOTICE("sign_out_notice"), NOTIFICATIONS_SYSTEM_NOTICE("notifications_system_notice"), VIEW_FULL_PROFILE("view_full_profile"), @@ -110,5 +113,19 @@ enum class HyperskillAnalyticTarget(val targetName: String) { DAILY_STUDY_REMINDERS_HOUR_INTERVAL_PICKER_MODAL("daily_study_reminders_hour_interval_picker_modal"), CONFIRM("confirm"), GO_TO_FIRST_PROBLEM("go_to_first_problem"), - INTERVIEW_PREPARATION_COMPLETED_MODAL("interview_preparation_completed_modal") + INTERVIEW_PREPARATION_COMPLETED_MODAL("interview_preparation_completed_modal"), + HOME_SCREEN_QUICK_ACTION("home_screen_quick_action"), + REQUEST_REVIEW_MODAL("request_review_modal"), + WRITE_A_REQUEST("write_a_request"), + MAYBE_LATER("maybe_later"), + CHOICE("choice"), + SOLVE_ON_THE_WEB_VERSION("solve_on_the_web_version"), + ACTIVE_SUBSCRIPTION_DETAILS("active_subscription_details"), + SUBSCRIPTION_SUGGESTION_DETAILS("subscription_suggestion_details"), + BUY_SUBSCRIPTION("buy_subscription"), + CONTINUE_WITH_LIMITS("continue_with_limits"), + UNLOCK_UNLIMITED_PROBLEMS("unlock_unlimited_problems"), + MANAGE_SUBSCRIPTION("manage_subscription"), + RENEW_SUBSCRIPTION("renew_subscription"), + HYPERSKILL_TERMS_OF_SERVICE_AND_PRIVACY_POLICY("hyperskill_terms_of_service_and_privacy_policy") } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/injection/ChallengeWidgetComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/injection/ChallengeWidgetComponentImpl.kt index 4dfd1bdceb..0d07994c61 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/injection/ChallengeWidgetComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/injection/ChallengeWidgetComponentImpl.kt @@ -13,7 +13,7 @@ internal class ChallengeWidgetComponentImpl(private val appGraph: AppGraph) : Ch override val challengeWidgetActionDispatcher: ChallengeWidgetActionDispatcher get() = ChallengeWidgetActionDispatcher( config = ActionDispatcherOptions(), - solvedStepsSharedFlow = appGraph.submissionDataComponent.submissionRepository.solvedStepsSharedFlow, + stepCompletedFlow = appGraph.stepCompletionFlowDataComponent.stepCompletedFlow, topicCompletedFlow = appGraph.stepCompletionFlowDataComponent.topicCompletedFlow, dailyStepCompletedFlow = appGraph.stepCompletionFlowDataComponent.dailyStepCompletedFlow, challengesRepository = appGraph.buildChallengesDataComponent().challengesRepository, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetActionDispatcher.kt index 8ff0afd4f6..e37e8659b2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/challenges/widget/presentation/ChallengeWidgetActionDispatcher.kt @@ -4,7 +4,6 @@ import kotlin.time.DurationUnit import kotlin.time.toDuration import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn @@ -21,12 +20,13 @@ import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder import org.hyperskill.app.sentry.domain.withTransaction import org.hyperskill.app.step_completion.domain.flow.DailyStepCompletedFlow +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow import org.hyperskill.app.step_completion.domain.flow.TopicCompletedFlow import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher class ChallengeWidgetActionDispatcher( config: ActionDispatcherOptions, - solvedStepsSharedFlow: SharedFlow, + stepCompletedFlow: StepCompletedFlow, topicCompletedFlow: TopicCompletedFlow, dailyStepCompletedFlow: DailyStepCompletedFlow, private val challengesRepository: ChallengesRepository, @@ -41,7 +41,7 @@ class ChallengeWidgetActionDispatcher( } init { - solvedStepsSharedFlow + stepCompletedFlow.observe() .distinctUntilChanged() .onEach { onNewMessage(InternalMessage.StepSolved) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/domain/platform/Platform.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/domain/platform/Platform.kt index 74cd19ac19..e22b5f7836 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/domain/platform/Platform.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/domain/platform/Platform.kt @@ -11,4 +11,9 @@ expect class Platform() { val feedbackName: String val appNameResource: StringResource + + /** + * A boolean flag that indicates whether the platform supports subscription purchase. + */ + val isSubscriptionPurchaseEnabled: Boolean } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/domain/url/HyperskillUrlPath.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/domain/url/HyperskillUrlPath.kt index f124cf81ba..7f802e91a6 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/domain/url/HyperskillUrlPath.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/domain/url/HyperskillUrlPath.kt @@ -1,5 +1,7 @@ package org.hyperskill.app.core.domain.url +import org.hyperskill.app.step.domain.model.StepRoute + sealed class HyperskillUrlPath { abstract val path: String @@ -30,4 +32,8 @@ sealed class HyperskillUrlPath { class DeleteAccount : HyperskillUrlPath() { override val path: String = "/delete-account" } + + class Step(stepRoute: StepRoute) : HyperskillUrlPath() { + override val path: String = stepRoute.analyticRoute.path + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt index 4e10af041d..94ed989e74 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt @@ -13,7 +13,6 @@ import org.hyperskill.app.debug.injection.DebugComponent import org.hyperskill.app.devices.injection.DevicesDataComponent import org.hyperskill.app.discussions.injection.DiscussionsDataComponent import org.hyperskill.app.first_problem_onboarding.injection.FirstProblemOnboardingComponent -import org.hyperskill.app.freemium.injection.FreemiumDataComponent import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarScreen import org.hyperskill.app.gamification_toolbar.injection.GamificationToolbarComponent import org.hyperskill.app.home.injection.HomeComponent @@ -29,6 +28,7 @@ import org.hyperskill.app.logging.inject.LoggerComponent import org.hyperskill.app.magic_links.injection.MagicLinksDataComponent import org.hyperskill.app.main.injection.MainComponent import org.hyperskill.app.main.injection.MainDataComponent +import org.hyperskill.app.manage_subscription.injection.ManageSubscriptionComponent import org.hyperskill.app.network.injection.NetworkComponent import org.hyperskill.app.notification.click_handling.injection.NotificationClickHandlingComponent import org.hyperskill.app.notification.local.injection.NotificationComponent @@ -37,6 +37,8 @@ import org.hyperskill.app.notification.remote.injection.PlatformPushNotification import org.hyperskill.app.notification.remote.injection.PushNotificationsComponent import org.hyperskill.app.notifications_onboarding.injection.NotificationsOnboardingComponent import org.hyperskill.app.onboarding.injection.OnboardingDataComponent +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.paywall.injection.PaywallComponent import org.hyperskill.app.problems_limit.domain.model.ProblemsLimitScreen import org.hyperskill.app.problems_limit.injection.ProblemsLimitComponent import org.hyperskill.app.products.injection.ProductsDataComponent @@ -50,7 +52,10 @@ import org.hyperskill.app.project_selection.details.injection.ProjectSelectionDe import org.hyperskill.app.project_selection.list.injection.ProjectSelectionListComponent import org.hyperskill.app.projects.injection.ProjectsDataComponent import org.hyperskill.app.providers.injection.ProvidersDataComponent +import org.hyperskill.app.purchases.injection.PurchaseComponent import org.hyperskill.app.reactions.injection.ReactionsDataComponent +import org.hyperskill.app.request_review.injection.RequestReviewDataComponent +import org.hyperskill.app.request_review.modal.injection.RequestReviewModalComponent import org.hyperskill.app.search.injection.SearchComponent import org.hyperskill.app.search_results.injection.SearchResultsDataComponent import org.hyperskill.app.sentry.injection.SentryComponent @@ -70,6 +75,7 @@ import org.hyperskill.app.streaks.injection.StreakFlowDataComponent import org.hyperskill.app.streaks.injection.StreaksDataComponent import org.hyperskill.app.study_plan.screen.injection.StudyPlanScreenComponent import org.hyperskill.app.study_plan.widget.injection.StudyPlanWidgetComponent +import org.hyperskill.app.subscriptions.injection.SubscriptionsDataComponent import org.hyperskill.app.topics.injection.TopicsDataComponent import org.hyperskill.app.topics_repetitions.injection.TopicsRepetitionsComponent import org.hyperskill.app.topics_repetitions.injection.TopicsRepetitionsDataComponent @@ -78,6 +84,9 @@ import org.hyperskill.app.track.injection.TrackDataComponent import org.hyperskill.app.track_selection.details.injection.TrackSelectionDetailsComponent import org.hyperskill.app.track_selection.list.injection.TrackSelectionListComponent import org.hyperskill.app.user_storage.injection.UserStorageComponent +import org.hyperskill.app.users_questionnaire.injection.UsersQuestionnaireDataComponent +import org.hyperskill.app.users_questionnaire.onboarding.injection.UsersQuestionnaireOnboardingComponent +import org.hyperskill.app.users_questionnaire.widget.injection.UsersQuestionnaireWidgetComponent import org.hyperskill.app.welcome.injection.WelcomeComponent import org.hyperskill.app.welcome.injection.WelcomeDataComponent import org.hyperskill.app.welcome_onboarding.injection.WelcomeOnboardingComponent @@ -90,7 +99,6 @@ interface AppGraph { val mainComponent: MainComponent val analyticComponent: AnalyticComponent val sentryComponent: SentryComponent - val submissionDataComponent: SubmissionDataComponent val streakFlowDataComponent: StreakFlowDataComponent val topicsRepetitionsFlowDataComponent: TopicsRepetitionsFlowDataComponent val stepCompletionFlowDataComponent: StepCompletionFlowDataComponent @@ -98,9 +106,12 @@ interface AppGraph { val notificationFlowDataComponent: NotificationFlowDataComponent val stateRepositoriesComponent: StateRepositoriesComponent val profileDataComponent: ProfileDataComponent + val subscriptionDataComponent: SubscriptionsDataComponent fun buildHyperskillAnalyticEngineComponent(): HyperskillAnalyticEngineComponent + fun buildPurchaseComponent(): PurchaseComponent + /** * Auth components */ @@ -117,6 +128,8 @@ interface AppGraph { fun buildStepCompletionComponent(stepRoute: StepRoute): StepCompletionComponent fun buildStageImplementComponent(projectId: Long, stageId: Long): StageImplementComponent + fun buildSubmissionDataComponent(): SubmissionDataComponent + fun buildStudyPlanWidgetComponent(): StudyPlanWidgetComponent fun buildStudyPlanScreenComponent(): StudyPlanScreenComponent @@ -152,7 +165,6 @@ interface AppGraph { fun buildProjectSelectionListComponent(): ProjectSelectionListComponent fun buildProjectSelectionDetailsComponent(): ProjectSelectionDetailsComponent fun buildStagesDataComponent(): StagesDataComponent - fun buildFreemiumDataComponent(): FreemiumDataComponent fun buildProblemsLimitComponent(screen: ProblemsLimitScreen): ProblemsLimitComponent fun buildProvidersDataComponent(): ProvidersDataComponent fun buildStreakRecoveryComponent(): StreakRecoveryComponent @@ -175,4 +187,11 @@ interface AppGraph { fun buildWelcomeOnboardingComponent(): WelcomeOnboardingComponent fun buildInterviewPreparationWidgetComponent(): InterviewPreparationWidgetComponent fun buildInterviewPreparationOnboardingComponent(): InterviewPreparationOnboardingComponent + fun buildRequestReviewDataComponent(): RequestReviewDataComponent + fun buildRequestReviewModalComponent(stepRoute: StepRoute): RequestReviewModalComponent + fun buildPaywallComponent(paywallTransitionSource: PaywallTransitionSource): PaywallComponent + fun buildManageSubscriptionComponent(): ManageSubscriptionComponent + fun buildUsersQuestionnaireDataComponent(): UsersQuestionnaireDataComponent + fun buildUsersQuestionnaireWidgetComponent(): UsersQuestionnaireWidgetComponent + fun buildUsersQuestionnaireOnboardingComponent(): UsersQuestionnaireOnboardingComponent } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt index 905bed3667..d9b7c4e608 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt @@ -24,8 +24,6 @@ import org.hyperskill.app.discussions.injection.DiscussionsDataComponent import org.hyperskill.app.discussions.injection.DiscussionsDataComponentImpl import org.hyperskill.app.first_problem_onboarding.injection.FirstProblemOnboardingComponent import org.hyperskill.app.first_problem_onboarding.injection.FirstProblemOnboardingComponentImpl -import org.hyperskill.app.freemium.injection.FreemiumDataComponent -import org.hyperskill.app.freemium.injection.FreemiumDataComponentImpl import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarScreen import org.hyperskill.app.gamification_toolbar.injection.GamificationToolbarComponent import org.hyperskill.app.gamification_toolbar.injection.GamificationToolbarComponentImpl @@ -55,6 +53,8 @@ import org.hyperskill.app.main.injection.MainComponent import org.hyperskill.app.main.injection.MainComponentImpl import org.hyperskill.app.main.injection.MainDataComponent import org.hyperskill.app.main.injection.MainDataComponentImpl +import org.hyperskill.app.manage_subscription.injection.ManageSubscriptionComponent +import org.hyperskill.app.manage_subscription.injection.ManageSubscriptionComponentImpl import org.hyperskill.app.network.injection.NetworkComponent import org.hyperskill.app.network.injection.NetworkComponentImpl import org.hyperskill.app.notification.click_handling.injection.NotificationClickHandlingComponent @@ -69,6 +69,9 @@ import org.hyperskill.app.notifications_onboarding.injection.NotificationsOnboar import org.hyperskill.app.notifications_onboarding.injection.NotificationsOnboardingComponentImpl import org.hyperskill.app.onboarding.injection.OnboardingDataComponent import org.hyperskill.app.onboarding.injection.OnboardingDataComponentImpl +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.paywall.injection.PaywallComponent +import org.hyperskill.app.paywall.injection.PaywallComponentImpl import org.hyperskill.app.problems_limit.domain.model.ProblemsLimitScreen import org.hyperskill.app.problems_limit.injection.ProblemsLimitComponent import org.hyperskill.app.problems_limit.injection.ProblemsLimitComponentImpl @@ -96,6 +99,10 @@ import org.hyperskill.app.providers.injection.ProvidersDataComponent import org.hyperskill.app.providers.injection.ProvidersDataComponentImpl import org.hyperskill.app.reactions.injection.ReactionsDataComponent import org.hyperskill.app.reactions.injection.ReactionsDataComponentImpl +import org.hyperskill.app.request_review.injection.RequestReviewDataComponent +import org.hyperskill.app.request_review.injection.RequestReviewDataComponentImpl +import org.hyperskill.app.request_review.modal.injection.RequestReviewModalComponent +import org.hyperskill.app.request_review.modal.injection.RequestReviewModalComponentImpl import org.hyperskill.app.search.injection.SearchComponent import org.hyperskill.app.search.injection.SearchComponentImpl import org.hyperskill.app.search_results.injection.SearchResultsDataComponent @@ -131,6 +138,8 @@ import org.hyperskill.app.study_plan.screen.injection.StudyPlanScreenComponent import org.hyperskill.app.study_plan.screen.injection.StudyPlanScreenComponentImpl import org.hyperskill.app.study_plan.widget.injection.StudyPlanWidgetComponent import org.hyperskill.app.study_plan.widget.injection.StudyPlanWidgetComponentImpl +import org.hyperskill.app.subscriptions.injection.SubscriptionsDataComponent +import org.hyperskill.app.subscriptions.injection.SubscriptionsDataComponentImpl import org.hyperskill.app.topics.injection.TopicsDataComponent import org.hyperskill.app.topics.injection.TopicsDataComponentImpl import org.hyperskill.app.topics_repetitions.injection.TopicsRepetitionsComponent @@ -147,6 +156,12 @@ import org.hyperskill.app.track_selection.list.injection.TrackSelectionListCompo import org.hyperskill.app.track_selection.list.injection.TrackSelectionListComponentImpl import org.hyperskill.app.user_storage.injection.UserStorageComponent import org.hyperskill.app.user_storage.injection.UserStorageComponentImpl +import org.hyperskill.app.users_questionnaire.injection.UsersQuestionnaireDataComponent +import org.hyperskill.app.users_questionnaire.injection.UsersQuestionnaireDataComponentImpl +import org.hyperskill.app.users_questionnaire.onboarding.injection.UsersQuestionnaireOnboardingComponent +import org.hyperskill.app.users_questionnaire.onboarding.injection.UsersQuestionnaireOnboardingComponentImpl +import org.hyperskill.app.users_questionnaire.widget.injection.UsersQuestionnaireWidgetComponent +import org.hyperskill.app.users_questionnaire.widget.injection.UsersQuestionnaireWidgetComponentImpl import org.hyperskill.app.welcome.injection.WelcomeComponent import org.hyperskill.app.welcome.injection.WelcomeComponentImpl import org.hyperskill.app.welcome.injection.WelcomeDataComponent @@ -168,10 +183,6 @@ abstract class BaseAppGraph : AppGraph { LoggerComponentImpl(this) } - override val submissionDataComponent: SubmissionDataComponent by lazy { - SubmissionDataComponentImpl(this) - } - override val authComponent: AuthComponent by lazy { AuthComponentImpl(this) } @@ -203,11 +214,14 @@ abstract class BaseAppGraph : AppGraph { override val profileDataComponent: ProfileDataComponent by lazy { ProfileDataComponentImpl( networkComponent = networkComponent, - commonComponent = commonComponent, - submissionDataComponent = submissionDataComponent + commonComponent = commonComponent ) } + override val subscriptionDataComponent: SubscriptionsDataComponent by lazy { + SubscriptionsDataComponentImpl(this) + } + override fun buildHyperskillAnalyticEngineComponent(): HyperskillAnalyticEngineComponent = HyperskillAnalyticEngineComponentImpl(this) @@ -274,6 +288,9 @@ abstract class BaseAppGraph : AppGraph { override fun buildStageImplementComponent(projectId: Long, stageId: Long): StageImplementComponent = StageImplementComponentImpl(this, projectId = projectId, stageId = stageId) + override fun buildSubmissionDataComponent(): SubmissionDataComponent = + SubmissionDataComponentImpl(this) + override fun buildTrackDataComponent(): TrackDataComponent = TrackDataComponentImpl(this) @@ -412,9 +429,6 @@ abstract class BaseAppGraph : AppGraph { override fun buildStagesDataComponent(): StagesDataComponent = StagesDataComponentImpl(this) - override fun buildFreemiumDataComponent(): FreemiumDataComponent = - FreemiumDataComponentImpl(this) - override fun buildProvidersDataComponent(): ProvidersDataComponent = ProvidersDataComponentImpl(this) @@ -474,4 +488,27 @@ abstract class BaseAppGraph : AppGraph { override fun buildInterviewPreparationOnboardingComponent(): InterviewPreparationOnboardingComponent = InterviewPreparationOnboardingComponentImpl(this) + + override fun buildRequestReviewDataComponent(): RequestReviewDataComponent = + RequestReviewDataComponentImpl(this) + + override fun buildRequestReviewModalComponent(stepRoute: StepRoute): RequestReviewModalComponent = + RequestReviewModalComponentImpl(appGraph = this, stepRoute = stepRoute) + + override fun buildPaywallComponent( + paywallTransitionSource: PaywallTransitionSource + ): PaywallComponent = + PaywallComponentImpl(paywallTransitionSource, this) + + override fun buildManageSubscriptionComponent(): ManageSubscriptionComponent = + ManageSubscriptionComponentImpl(this) + + override fun buildUsersQuestionnaireDataComponent(): UsersQuestionnaireDataComponent = + UsersQuestionnaireDataComponentImpl(this) + + override fun buildUsersQuestionnaireWidgetComponent(): UsersQuestionnaireWidgetComponent = + UsersQuestionnaireWidgetComponentImpl(this) + + override fun buildUsersQuestionnaireOnboardingComponent(): UsersQuestionnaireOnboardingComponent = + UsersQuestionnaireOnboardingComponentImpl(this) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/StateRepositoriesComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/StateRepositoriesComponentImpl.kt index a67cf06873..6602143df8 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/StateRepositoriesComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/StateRepositoriesComponentImpl.kt @@ -12,7 +12,9 @@ import org.hyperskill.app.learning_activities.remote.LearningActivitiesRemoteDat import org.hyperskill.app.study_plan.data.repository.CurrentStudyPlanStateRepositoryImpl import org.hyperskill.app.study_plan.domain.repository.CurrentStudyPlanStateRepository import org.hyperskill.app.study_plan.remote.StudyPlanRemoteDataSourceImpl +import org.hyperskill.app.subscriptions.cache.CurrentSubscriptionStateHolderImpl import org.hyperskill.app.subscriptions.data.repository.CurrentSubscriptionStateRepositoryImpl +import org.hyperskill.app.subscriptions.data.source.CurrentSubscriptionStateHolder import org.hyperskill.app.subscriptions.data.source.SubscriptionsRemoteDataSource import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository import org.hyperskill.app.subscriptions.remote.SubscriptionsRemoteDataSourceImpl @@ -27,8 +29,15 @@ class StateRepositoriesComponentImpl(appGraph: AppGraph) : StateRepositoriesComp private val subscriptionsRemoteDataSource: SubscriptionsRemoteDataSource = SubscriptionsRemoteDataSourceImpl(authorizedHttpClient) + private val currentSubscriptionStateHolder: CurrentSubscriptionStateHolder by lazy { + CurrentSubscriptionStateHolderImpl( + json = appGraph.commonComponent.json, + settings = appGraph.commonComponent.settings + ) + } + override val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository = - CurrentSubscriptionStateRepositoryImpl(subscriptionsRemoteDataSource) + CurrentSubscriptionStateRepositoryImpl(subscriptionsRemoteDataSource, currentSubscriptionStateHolder) /** * Study plan diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/MonthFormatter.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/MonthFormatter.kt index 50989299c3..a360567111 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/MonthFormatter.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/MonthFormatter.kt @@ -25,4 +25,21 @@ internal object MonthFormatter { Month.DECEMBER -> "Dec" else -> throw IllegalArgumentException("MonthFormatter: unknown month $month") } + + fun formatMonth(month: Month): String = + when (month) { + Month.JANUARY -> "January" + Month.FEBRUARY -> "February" + Month.MARCH -> "March" + Month.APRIL -> "April" + Month.MAY -> "May" + Month.JUNE -> "June" + Month.JULY -> "July" + Month.AUGUST -> "August" + Month.SEPTEMBER -> "September" + Month.OCTOBER -> "October" + Month.NOVEMBER -> "November" + Month.DECEMBER -> "December" + else -> throw IllegalArgumentException("MonthFormatter: unknown month $month") + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/SharedDateFormatter.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/SharedDateFormatter.kt index 9bc4e39891..f8c3f0d69f 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/SharedDateFormatter.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/view/mapper/date/SharedDateFormatter.kt @@ -4,7 +4,10 @@ import kotlin.math.max import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.toDuration +import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import org.hyperskill.app.SharedResources import org.hyperskill.app.core.view.mapper.ResourceProvider @@ -229,4 +232,16 @@ class SharedDateFormatter(private val resourceProvider: ResourceProvider) { */ fun formatDayNumericAndMonthShort(localDate: LocalDate): String = "${localDate.dayOfMonth} ${MonthFormatter.formatMonthToShort(localDate.month)}" + + fun formatSubscriptionValidUntil(instant: Instant, timeZone: TimeZone = TimeZone.currentSystemDefault()): String { + val localDateTime = instant.toLocalDateTime(timeZone) + val month = MonthFormatter.formatMonth(localDateTime.month) + val dayOfMonth = localDateTime.date.dayOfMonth + val hour = formatHoursOrMinutesWithLeadingZero(localDateTime.hour) + val minutes = formatHoursOrMinutesWithLeadingZero(localDateTime.minute) + return "$month $dayOfMonth, ${localDateTime.year}, $hour:$minutes" + } + + private fun formatHoursOrMinutesWithLeadingZero(count: Int): String = + if (count <= 9) "0$count" else count.toString() } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/domain/analytic/FirstProblemOnboardingClickedLearningActionHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/domain/analytic/FirstProblemOnboardingClickedLearningActionHyperskillAnalyticEvent.kt similarity index 98% rename from shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/domain/analytic/FirstProblemOnboardingClickedLearningActionHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/domain/analytic/FirstProblemOnboardingClickedLearningActionHyperskillAnalyticEvent.kt index 6c3a010d38..e7c00d9ea2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/domain/analytic/FirstProblemOnboardingClickedLearningActionHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/domain/analytic/FirstProblemOnboardingClickedLearningActionHyperskillAnalyticEvent.kt @@ -18,9 +18,10 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * "target": "start_learning/keep_learning" * } * ``` + * * @see HyperskillAnalyticEvent */ -class FirstProblemOnboardingClickedLearningActionHyperskillAnalyticsEvent( +class FirstProblemOnboardingClickedLearningActionHyperskillAnalyticEvent( target: HyperskillAnalyticTarget ) : HyperskillAnalyticEvent( route = HyperskillAnalyticRoute.Onboarding.FirstProblem, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/domain/analytic/FirstProblemOnboardingViewedHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/domain/analytic/FirstProblemOnboardingViewedHyperskillAnalyticEvent.kt similarity index 74% rename from shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/domain/analytic/FirstProblemOnboardingViewedHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/domain/analytic/FirstProblemOnboardingViewedHyperskillAnalyticEvent.kt index c4c1b184a0..51f9ca30d3 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/domain/analytic/FirstProblemOnboardingViewedHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/domain/analytic/FirstProblemOnboardingViewedHyperskillAnalyticEvent.kt @@ -14,7 +14,10 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRou * "action": "view" * } * ``` + * * @see HyperskillAnalyticEvent */ -object FirstProblemOnboardingViewedHyperskillAnalyticsEvent : - HyperskillAnalyticEvent(HyperskillAnalyticRoute.Onboarding.FirstProblem, HyperskillAnalyticAction.VIEW) \ No newline at end of file +object FirstProblemOnboardingViewedHyperskillAnalyticEvent : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Onboarding.FirstProblem, + HyperskillAnalyticAction.VIEW +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/presentation/FirstProblemOnboardingReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/presentation/FirstProblemOnboardingReducer.kt index 565d93e6f8..084214c9b9 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/presentation/FirstProblemOnboardingReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/first_problem_onboarding/presentation/FirstProblemOnboardingReducer.kt @@ -1,8 +1,8 @@ package org.hyperskill.app.first_problem_onboarding.presentation import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget -import org.hyperskill.app.first_problem_onboarding.domain.analytic.FirstProblemOnboardingClickedLearningActionHyperskillAnalyticsEvent -import org.hyperskill.app.first_problem_onboarding.domain.analytic.FirstProblemOnboardingViewedHyperskillAnalyticsEvent +import org.hyperskill.app.first_problem_onboarding.domain.analytic.FirstProblemOnboardingClickedLearningActionHyperskillAnalyticEvent +import org.hyperskill.app.first_problem_onboarding.domain.analytic.FirstProblemOnboardingViewedHyperskillAnalyticEvent import org.hyperskill.app.first_problem_onboarding.presentation.FirstProblemOnboardingFeature.Action import org.hyperskill.app.first_problem_onboarding.presentation.FirstProblemOnboardingFeature.InternalAction import org.hyperskill.app.first_problem_onboarding.presentation.FirstProblemOnboardingFeature.Message @@ -126,7 +126,7 @@ internal class FirstProblemOnboardingReducer : StateReducer = - currentSubscriptionStateRepository.getState().map { it.isFreemium } - - suspend fun getStepsLimitTotal(): Result = - currentSubscriptionStateRepository.getState().map { it.stepsLimitTotal } - - suspend fun isProblemsLimitReached(): Result = - kotlin.runCatching { - if (isFreemiumEnabled().getOrThrow()) { - val subscription = currentSubscriptionStateRepository - .getState() - .getOrThrow() - - subscription.isFreemium && subscription.stepsLimitLeft == 0 - } else { - false - } - } - - suspend fun onStepSolved() { - if (isFreemiumEnabled().getOrDefault(false)) { - currentSubscriptionStateRepository.getState().getOrNull()?.let { - currentSubscriptionStateRepository.updateState( - it.copy(stepsLimitLeft = it.stepsLimitLeft?.dec()) - ) - } - } - currentProfileStateRepository.getState().onSuccess { currentProfile -> - if (currentProfile.features.isFreemiumIncreaseLimitsForFirstStepCompletionEnabled && - currentProfile.gamification.passedProblems == 0 - ) { - currentSubscriptionStateRepository.updateState { - it.copy( - stepsLimitTotal = it.stepsLimitTotal?.plus(SOLVING_FIRST_STEP_ADDITIONAL_LIMIT_VALUE), - stepsLimitLeft = it.stepsLimitLeft?.plus(SOLVING_FIRST_STEP_ADDITIONAL_LIMIT_VALUE) - ) - } - currentProfileStateRepository.updateState { - it.copy(gamification = it.gamification.copy(passedProblems = it.gamification.passedProblems + 1)) - } - } - } - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/freemium/injection/FreemiumDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/freemium/injection/FreemiumDataComponent.kt deleted file mode 100644 index 762be62d7e..0000000000 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/freemium/injection/FreemiumDataComponent.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.hyperskill.app.freemium.injection - -import org.hyperskill.app.freemium.domain.interactor.FreemiumInteractor - -interface FreemiumDataComponent { - val freemiumInteractor: FreemiumInteractor -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/freemium/injection/FreemiumDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/freemium/injection/FreemiumDataComponentImpl.kt deleted file mode 100644 index 9c85ae425e..0000000000 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/freemium/injection/FreemiumDataComponentImpl.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.hyperskill.app.freemium.injection - -import org.hyperskill.app.core.injection.AppGraph -import org.hyperskill.app.freemium.domain.interactor.FreemiumInteractor -import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository -import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository - -class FreemiumDataComponentImpl( - appGraph: AppGraph -) : FreemiumDataComponent { - private val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository = - appGraph.stateRepositoriesComponent.currentSubscriptionStateRepository - - private val currentProfileStateRepository: CurrentProfileStateRepository = - appGraph.profileDataComponent.currentProfileStateRepository - - override val freemiumInteractor: FreemiumInteractor - get() = FreemiumInteractor(currentSubscriptionStateRepository, currentProfileStateRepository) -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/injection/GamificationToolbarComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/injection/GamificationToolbarComponentImpl.kt index d60e5e4d43..f0c34f5336 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/injection/GamificationToolbarComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/injection/GamificationToolbarComponentImpl.kt @@ -6,7 +6,7 @@ import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarS import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarActionDispatcher import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarReducer -class GamificationToolbarComponentImpl( +internal class GamificationToolbarComponentImpl( private val appGraph: AppGraph, private val screen: GamificationToolbarScreen ) : GamificationToolbarComponent { @@ -16,7 +16,7 @@ class GamificationToolbarComponentImpl( override val gamificationToolbarActionDispatcher: GamificationToolbarActionDispatcher get() = GamificationToolbarActionDispatcher( config = ActionDispatcherOptions(), - submissionRepository = appGraph.submissionDataComponent.submissionRepository, + stepCompletedFlow = appGraph.stepCompletionFlowDataComponent.stepCompletedFlow, streakFlow = appGraph.streakFlowDataComponent.streakFlow, currentStudyPlanStateRepository = appGraph.stateRepositoriesComponent.currentStudyPlanStateRepository, topicCompletedFlow = appGraph.stepCompletionFlowDataComponent.topicCompletedFlow, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/GamificationToolbarActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/GamificationToolbarActionDispatcher.kt index 12e59b2842..181c4ad537 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/GamificationToolbarActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/GamificationToolbarActionDispatcher.kt @@ -12,15 +12,15 @@ import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarF import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature.Message import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.sentry.domain.withTransaction +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow import org.hyperskill.app.step_completion.domain.flow.TopicCompletedFlow -import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository import org.hyperskill.app.streaks.domain.flow.StreakFlow import org.hyperskill.app.study_plan.domain.repository.CurrentStudyPlanStateRepository import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher class GamificationToolbarActionDispatcher( config: ActionDispatcherOptions, - submissionRepository: SubmissionRepository, + stepCompletedFlow: StepCompletedFlow, streakFlow: StreakFlow, currentStudyPlanStateRepository: CurrentStudyPlanStateRepository, topicCompletedFlow: TopicCompletedFlow, @@ -30,7 +30,7 @@ class GamificationToolbarActionDispatcher( ) : CoroutineActionDispatcher(config.createConfig()) { init { - submissionRepository.solvedStepsSharedFlow + stepCompletedFlow.observe() .onEach { onNewMessage(InternalMessage.StepSolved) } .launchIn(actionScope) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/domain/interactor/HomeInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/domain/interactor/HomeInteractor.kt deleted file mode 100644 index 49e6869344..0000000000 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/home/domain/interactor/HomeInteractor.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.hyperskill.app.home.domain.interactor - -import kotlinx.coroutines.flow.SharedFlow -import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository - -class HomeInteractor( - submissionRepository: SubmissionRepository -) { - val solvedStepsSharedFlow: SharedFlow = submissionRepository.solvedStepsMutableSharedFlow -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeComponentImpl.kt index f83913f8a8..0ac28570cc 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeComponentImpl.kt @@ -4,15 +4,11 @@ import org.hyperskill.app.challenges.widget.injection.ChallengeWidgetComponent import org.hyperskill.app.core.injection.AppGraph import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarScreen import org.hyperskill.app.gamification_toolbar.injection.GamificationToolbarComponent -import org.hyperskill.app.home.domain.interactor.HomeInteractor import org.hyperskill.app.home.presentation.HomeFeature import org.hyperskill.app.interview_preparation.injection.InterviewPreparationWidgetComponent import ru.nobird.app.presentation.redux.feature.Feature internal class HomeComponentImpl(private val appGraph: AppGraph) : HomeComponent { - private val homeInteractor: HomeInteractor = - HomeInteractor(appGraph.submissionDataComponent.submissionRepository) - private val gamificationToolbarComponent: GamificationToolbarComponent = appGraph.buildGamificationToolbarComponent(GamificationToolbarScreen.HOME) @@ -24,15 +20,16 @@ internal class HomeComponentImpl(private val appGraph: AppGraph) : HomeComponent override val homeFeature: Feature get() = HomeFeatureBuilder.build( - homeInteractor, appGraph.profileDataComponent.currentProfileStateRepository, appGraph.buildTopicsRepetitionsDataComponent().topicsRepetitionsInteractor, appGraph.buildStepDataComponent().stepInteractor, - appGraph.buildFreemiumDataComponent().freemiumInteractor, + appGraph.stateRepositoriesComponent.currentSubscriptionStateRepository, appGraph.analyticComponent.analyticInteractor, appGraph.sentryComponent.sentryInteractor, appGraph.commonComponent.dateFormatter, appGraph.topicsRepetitionsFlowDataComponent.topicRepeatedFlow, + appGraph.stepCompletionFlowDataComponent.topicCompletedFlow, + appGraph.stepCompletionFlowDataComponent.stepCompletedFlow, gamificationToolbarComponent.gamificationToolbarReducer, gamificationToolbarComponent.gamificationToolbarActionDispatcher, challengeWidgetComponent.challengeWidgetReducer, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeFeatureBuilder.kt index 589cfe479d..104dfe051f 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/home/injection/HomeFeatureBuilder.kt @@ -10,11 +10,9 @@ import org.hyperskill.app.core.domain.BuildVariant import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.core.presentation.transformState import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter -import org.hyperskill.app.freemium.domain.interactor.FreemiumInteractor import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarActionDispatcher import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarFeature import org.hyperskill.app.gamification_toolbar.presentation.GamificationToolbarReducer -import org.hyperskill.app.home.domain.interactor.HomeInteractor import org.hyperskill.app.home.presentation.HomeActionDispatcher import org.hyperskill.app.home.presentation.HomeFeature import org.hyperskill.app.home.presentation.HomeReducer @@ -27,6 +25,9 @@ import org.hyperskill.app.logging.presentation.wrapWithLogger import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.step.domain.interactor.StepInteractor +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow +import org.hyperskill.app.step_completion.domain.flow.TopicCompletedFlow +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository import org.hyperskill.app.topics_repetitions.domain.flow.TopicRepeatedFlow import org.hyperskill.app.topics_repetitions.domain.interactor.TopicsRepetitionsInteractor import ru.nobird.app.core.model.safeCast @@ -39,15 +40,16 @@ internal object HomeFeatureBuilder { private const val LOG_TAG = "HomeFeature" fun build( - homeInteractor: HomeInteractor, currentProfileStateRepository: CurrentProfileStateRepository, topicsRepetitionsInteractor: TopicsRepetitionsInteractor, stepInteractor: StepInteractor, - freemiumInteractor: FreemiumInteractor, + currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, analyticInteractor: AnalyticInteractor, sentryInteractor: SentryInteractor, dateFormatter: SharedDateFormatter, topicRepeatedFlow: TopicRepeatedFlow, + topicCompletedFlow: TopicCompletedFlow, + stepCompletedFlow: StepCompletedFlow, gamificationToolbarReducer: GamificationToolbarReducer, gamificationToolbarActionDispatcher: GamificationToolbarActionDispatcher, challengeWidgetReducer: ChallengeWidgetReducer, @@ -66,15 +68,16 @@ internal object HomeFeatureBuilder { ).wrapWithLogger(buildVariant, logger, LOG_TAG) val homeActionDispatcher = HomeActionDispatcher( ActionDispatcherOptions(), - homeInteractor, currentProfileStateRepository, topicsRepetitionsInteractor, stepInteractor, - freemiumInteractor, + currentSubscriptionStateRepository, analyticInteractor, sentryInteractor, dateFormatter, - topicRepeatedFlow + topicRepeatedFlow, + topicCompletedFlow, + stepCompletedFlow ) val homeViewStateMapper = HomeViewStateMapper( challengeWidgetViewStateMapper = challengeWidgetViewStateMapper, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeActionDispatcher.kt index 6c90b5ed7c..407d671675 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeActionDispatcher.kt @@ -13,31 +13,35 @@ import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.core.utils.DateTimeUtils import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter -import org.hyperskill.app.freemium.domain.interactor.FreemiumInteractor -import org.hyperskill.app.home.domain.interactor.HomeInteractor import org.hyperskill.app.home.presentation.HomeFeature.Action import org.hyperskill.app.home.presentation.HomeFeature.InternalAction +import org.hyperskill.app.home.presentation.HomeFeature.InternalMessage import org.hyperskill.app.home.presentation.HomeFeature.Message import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder import org.hyperskill.app.sentry.domain.withTransaction import org.hyperskill.app.step.domain.interactor.StepInteractor +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow +import org.hyperskill.app.step_completion.domain.flow.TopicCompletedFlow +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository +import org.hyperskill.app.subscriptions.domain.repository.areProblemsLimited import org.hyperskill.app.topics_repetitions.domain.flow.TopicRepeatedFlow import org.hyperskill.app.topics_repetitions.domain.interactor.TopicsRepetitionsInteractor import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher internal class HomeActionDispatcher( config: ActionDispatcherOptions, - homeInteractor: HomeInteractor, private val currentProfileStateRepository: CurrentProfileStateRepository, private val topicsRepetitionsInteractor: TopicsRepetitionsInteractor, private val stepInteractor: StepInteractor, - private val freemiumInteractor: FreemiumInteractor, + private val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, private val analyticInteractor: AnalyticInteractor, private val sentryInteractor: SentryInteractor, private val dateFormatter: SharedDateFormatter, - topicRepeatedFlow: TopicRepeatedFlow + topicRepeatedFlow: TopicRepeatedFlow, + topicCompletedFlow: TopicCompletedFlow, + stepCompletedFlow: StepCompletedFlow ) : CoroutineActionDispatcher(config.createConfig()) { private var isTimerLaunched: Boolean = false @@ -46,12 +50,16 @@ internal class HomeActionDispatcher( } init { - homeInteractor.solvedStepsSharedFlow - .onEach { onNewMessage(Message.StepQuizSolved(it)) } + stepCompletedFlow.observe() + .onEach { onNewMessage(InternalMessage.StepQuizSolved(it)) } .launchIn(actionScope) topicRepeatedFlow.observe() - .onEach { onNewMessage(Message.TopicRepeated) } + .onEach { onNewMessage(InternalMessage.TopicRepeated) } + .launchIn(actionScope) + + topicCompletedFlow.observe() + .onEach { onNewMessage(InternalMessage.TopicCompleted) } .launchIn(actionScope) } @@ -59,6 +67,8 @@ internal class HomeActionDispatcher( when (action) { is InternalAction.FetchHomeScreenData -> handleFetchHomeScreenData(::onNewMessage) + is InternalAction.FetchProblemOfDayState -> + handleFetchProblemOfDayState(::onNewMessage) is InternalAction.LaunchTimer -> { if (isTimerLaunched) { return @@ -103,14 +113,16 @@ internal class HomeActionDispatcher( val currentProfile = currentProfileStateRepository .getState(forceUpdate = true) // ALTAPPS-303: Get from remote to get a relevant problem of the day .getOrThrow() + val problemOfDayStateResult = async { getProblemOfDayState(currentProfile.dailyStep) } val repetitionsStateResult = async { getRepetitionsState() } - val isFreemiumEnabledResult = async { freemiumInteractor.isFreemiumEnabled() } + val areProblemsLimited = async { currentSubscriptionStateRepository.areProblemsLimited() } + setOf( Message.HomeSuccess( problemOfDayState = problemOfDayStateResult.await().getOrThrow(), repetitionsState = repetitionsStateResult.await().getOrThrow(), - isFreemiumEnabled = isFreemiumEnabledResult.await().getOrThrow() + areProblemsLimited = areProblemsLimited.await() ), Message.ReadyToLaunchNextProblemInTimer ) @@ -118,6 +130,22 @@ internal class HomeActionDispatcher( }.forEach(onNewMessage) } + private suspend fun handleFetchProblemOfDayState(onNewMessage: (Message) -> Unit) { + val currentProfile = currentProfileStateRepository + .getState(forceUpdate = true) + .getOrElse { return onNewMessage(InternalMessage.FetchProblemOfDayStateResultError) } + + getProblemOfDayState(currentProfile.dailyStep) + .fold( + onSuccess = { + onNewMessage(InternalMessage.FetchProblemOfDayStateResultSuccess(it)) + }, + onFailure = { + onNewMessage(InternalMessage.FetchProblemOfDayStateResultError) + } + ) + } + private suspend fun getProblemOfDayState(dailyStepId: Long?): Result = if (dailyStepId == null) { Result.success(HomeFeature.ProblemOfDayState.Empty) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeFeature.kt index 57089bae12..94003345ea 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeFeature.kt @@ -51,7 +51,7 @@ object HomeFeature { * * @property problemOfDayState Problem of the day state. * @property repetitionsState Topics repetitions state. - * @property isFreemiumEnabled A boolean flag that indicates about is freemium enabled. + * @property areProblemsLimited A boolean flag that indicates that problem limits are enabled. * @property isRefreshing A boolean flag that indicates about is pull-to-refresh is ongoing. * * @see Streak @@ -60,7 +60,7 @@ object HomeFeature { data class Content( val problemOfDayState: ProblemOfDayState, val repetitionsState: RepetitionsState, - val isFreemiumEnabled: Boolean, + val areProblemsLimited: Boolean, internal val isRefreshing: Boolean = false ) : HomeState @@ -111,7 +111,7 @@ object HomeFeature { data class HomeSuccess( val problemOfDayState: ProblemOfDayState, val repetitionsState: RepetitionsState, - val isFreemiumEnabled: Boolean + val areProblemsLimited: Boolean ) : Message object HomeFailure : Message @@ -121,9 +121,6 @@ object HomeFeature { object NextProblemInTimerStopped : Message data class HomeNextProblemInUpdate(val nextProblemIn: String) : Message - data class StepQuizSolved(val stepId: Long) : Message - object TopicRepeated : Message - object ClickedTopicsRepetitionsCard : Message object ClickedProblemOfDayCardReload : Message @@ -149,6 +146,15 @@ object HomeFeature { ) : Message } + internal sealed interface InternalMessage : Message { + data class StepQuizSolved(val stepId: Long) : InternalMessage + object TopicRepeated : InternalMessage + object TopicCompleted : InternalMessage + + object FetchProblemOfDayStateResultError : InternalMessage + data class FetchProblemOfDayStateResultSuccess(val problemOfDayState: ProblemOfDayState) : InternalMessage + } + sealed interface Action { sealed interface ViewAction : Action { sealed interface NavigateTo : ViewAction { @@ -177,6 +183,8 @@ object HomeFeature { object FetchHomeScreenData : InternalAction object LaunchTimer : InternalAction + object FetchProblemOfDayState : InternalAction + data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction /** diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeReducer.kt index e970c483c6..0a4e54b47e 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/home/presentation/HomeReducer.kt @@ -13,6 +13,7 @@ import org.hyperskill.app.home.domain.analytic.HomeViewedHyperskillAnalyticEvent import org.hyperskill.app.home.presentation.HomeFeature.Action import org.hyperskill.app.home.presentation.HomeFeature.HomeState import org.hyperskill.app.home.presentation.HomeFeature.InternalAction +import org.hyperskill.app.home.presentation.HomeFeature.InternalMessage import org.hyperskill.app.home.presentation.HomeFeature.Message import org.hyperskill.app.home.presentation.HomeFeature.State import org.hyperskill.app.interview_preparation.presentation.InterviewPreparationWidgetFeature @@ -36,7 +37,7 @@ internal class HomeReducer( homeState = HomeState.Content( problemOfDayState = message.problemOfDayState, repetitionsState = message.repetitionsState, - isFreemiumEnabled = message.isFreemiumEnabled + areProblemsLimited = message.areProblemsLimited ) ) to emptySet() } @@ -107,7 +108,7 @@ internal class HomeReducer( null } // Flow Messages - is Message.StepQuizSolved -> { + is InternalMessage.StepQuizSolved -> { if (state.homeState is HomeState.Content) { val problemOfDayState = if ( state.homeState.problemOfDayState is HomeFeature.ProblemOfDayState.NeedToSolve && @@ -126,7 +127,7 @@ internal class HomeReducer( null } } - is Message.TopicRepeated -> + is InternalMessage.TopicRepeated -> if ( state.homeState is HomeState.Content && state.homeState.repetitionsState is HomeFeature.RepetitionsState.Available @@ -141,6 +142,25 @@ internal class HomeReducer( } else { null } + InternalMessage.TopicCompleted -> + if (state.homeState is HomeState.Content && + state.homeState.problemOfDayState is HomeFeature.ProblemOfDayState.Empty + ) { + state to setOf(InternalAction.FetchProblemOfDayState) + } else { + null + } + InternalMessage.FetchProblemOfDayStateResultError -> null + is InternalMessage.FetchProblemOfDayStateResultSuccess -> + if (state.homeState is HomeState.Content) { + state.copy( + homeState = state.homeState.copy( + problemOfDayState = message.problemOfDayState + ) + ) to emptySet() + } else { + null + } is Message.ClickedTopicsRepetitionsCard -> if (state.homeState is HomeState.Content) { val isCompleted = state.homeState.repetitionsState is HomeFeature.RepetitionsState.Available && @@ -157,7 +177,7 @@ internal class HomeReducer( is Message.ClickedProblemOfDayCardReload -> { if (state.homeState is HomeState.Content) { val (newState, newActions) = initialize(state, forceUpdate = true) - val analyticsEvent = when (state.homeState.problemOfDayState) { + val analyticEvent = when (state.homeState.problemOfDayState) { HomeFeature.ProblemOfDayState.Empty -> null is HomeFeature.ProblemOfDayState.NeedToSolve -> { HomeClickedProblemOfDayCardReloadHyperskillAnalyticEvent( @@ -170,8 +190,8 @@ internal class HomeReducer( ) } } - val logEventAction = if (analyticsEvent != null) { - setOf(InternalAction.LogAnalyticEvent(analyticsEvent)) + val logEventAction = if (analyticEvent != null) { + setOf(InternalAction.LogAnalyticEvent(analyticEvent)) } else { emptySet() } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetClickedHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetClickedHyperskillAnalyticEvent.kt similarity index 89% rename from shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetClickedHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetClickedHyperskillAnalyticEvent.kt index ca3ec1f292..37f1be001f 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetClickedHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetClickedHyperskillAnalyticEvent.kt @@ -19,7 +19,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRou * * @see HyperskillAnalyticEvent */ -object InterviewPreparationWidgetClickedHyperskillAnalyticsEvent : HyperskillAnalyticEvent( +object InterviewPreparationWidgetClickedHyperskillAnalyticEvent : HyperskillAnalyticEvent( HyperskillAnalyticRoute.Home(), HyperskillAnalyticAction.CLICK, HyperskillAnalyticPart.INTERVIEW_PREPARATION_WIDGET diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetClickedRetryContentLoadingHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetClickedRetryContentLoadingHyperskillAnalyticEvent.kt similarity index 95% rename from shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetClickedRetryContentLoadingHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetClickedRetryContentLoadingHyperskillAnalyticEvent.kt index a8a550ba14..0189cb779d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetClickedRetryContentLoadingHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetClickedRetryContentLoadingHyperskillAnalyticEvent.kt @@ -21,7 +21,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * * @see HyperskillAnalyticEvent */ -object InterviewPreparationWidgetClickedRetryContentLoadingHyperskillAnalyticsEvent : HyperskillAnalyticEvent( +object InterviewPreparationWidgetClickedRetryContentLoadingHyperskillAnalyticEvent : HyperskillAnalyticEvent( HyperskillAnalyticRoute.Home(), HyperskillAnalyticAction.CLICK, HyperskillAnalyticPart.INTERVIEW_PREPARATION_WIDGET, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetViewedHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetViewedHyperskillAnalyticEvent.kt similarity index 87% rename from shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetViewedHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetViewedHyperskillAnalyticEvent.kt index ac8b1a29f7..f875014a7c 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetViewedHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/domain/analytic/InterviewPreparationWidgetViewedHyperskillAnalyticEvent.kt @@ -17,7 +17,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRou * * @see HyperskillAnalyticEvent */ -object InterviewPreparationWidgetViewedHyperskillAnalyticsEvent : HyperskillAnalyticEvent( +object InterviewPreparationWidgetViewedHyperskillAnalyticEvent : HyperskillAnalyticEvent( HyperskillAnalyticRoute.Home.InterviewPreparationWidget(), HyperskillAnalyticAction.VIEW ) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/presentation/InterviewPreparationWidgetReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/presentation/InterviewPreparationWidgetReducer.kt index 7c126d0d5f..6ecece2579 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/presentation/InterviewPreparationWidgetReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation/presentation/InterviewPreparationWidgetReducer.kt @@ -1,8 +1,8 @@ package org.hyperskill.app.interview_preparation.presentation -import org.hyperskill.app.interview_preparation.domain.analytic.InterviewPreparationWidgetClickedHyperskillAnalyticsEvent -import org.hyperskill.app.interview_preparation.domain.analytic.InterviewPreparationWidgetClickedRetryContentLoadingHyperskillAnalyticsEvent -import org.hyperskill.app.interview_preparation.domain.analytic.InterviewPreparationWidgetViewedHyperskillAnalyticsEvent +import org.hyperskill.app.interview_preparation.domain.analytic.InterviewPreparationWidgetClickedHyperskillAnalyticEvent +import org.hyperskill.app.interview_preparation.domain.analytic.InterviewPreparationWidgetClickedRetryContentLoadingHyperskillAnalyticEvent +import org.hyperskill.app.interview_preparation.domain.analytic.InterviewPreparationWidgetViewedHyperskillAnalyticEvent import org.hyperskill.app.interview_preparation.presentation.InterviewPreparationWidgetFeature.Action import org.hyperskill.app.interview_preparation.presentation.InterviewPreparationWidgetFeature.InternalAction import org.hyperskill.app.interview_preparation.presentation.InterviewPreparationWidgetFeature.InternalMessage @@ -106,7 +106,7 @@ class InterviewPreparationWidgetReducer : StateReducer { State.Loading(isLoadingSilently = false) to setOf( InternalAction.FetchInterviewSteps(true), InternalAction.LogAnalyticEvent( - InterviewPreparationWidgetClickedRetryContentLoadingHyperskillAnalyticsEvent + InterviewPreparationWidgetClickedRetryContentLoadingHyperskillAnalyticEvent ) ) } else { @@ -119,7 +119,7 @@ class InterviewPreparationWidgetReducer : StateReducer { if (state is State.Content) { state to setOf( InternalAction.LogAnalyticEvent( - InterviewPreparationWidgetClickedHyperskillAnalyticsEvent + InterviewPreparationWidgetClickedHyperskillAnalyticEvent ), InternalAction.FetchNextInterviewStep ) @@ -147,7 +147,7 @@ class InterviewPreparationWidgetReducer : StateReducer { if (state !is State.Idle) { state to setOf( InternalAction.LogAnalyticEvent( - InterviewPreparationWidgetViewedHyperskillAnalyticsEvent + InterviewPreparationWidgetViewedHyperskillAnalyticEvent ) ) } else { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/domain/analytic/InterviewPreparationOnboardingGoToProblemClickedAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/domain/analytic/InterviewPreparationOnboardingGoToProblemClickedAnalyticEvent.kt similarity index 91% rename from shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/domain/analytic/InterviewPreparationOnboardingGoToProblemClickedAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/domain/analytic/InterviewPreparationOnboardingGoToProblemClickedAnalyticEvent.kt index 390e77b03b..a35d994cd7 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/domain/analytic/InterviewPreparationOnboardingGoToProblemClickedAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/domain/analytic/InterviewPreparationOnboardingGoToProblemClickedAnalyticEvent.kt @@ -21,7 +21,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * * @see HyperskillAnalyticEvent */ -object InterviewPreparationOnboardingGoToProblemClickedAnalyticsEvent : HyperskillAnalyticEvent( +object InterviewPreparationOnboardingGoToProblemClickedAnalyticEvent : HyperskillAnalyticEvent( route = HyperskillAnalyticRoute.Onboarding.InterviewPreparation, action = HyperskillAnalyticAction.CLICK, part = HyperskillAnalyticPart.MAIN, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/domain/analytic/InterviewPreparationOnboardingViewedAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/domain/analytic/InterviewPreparationOnboardingViewedAnalyticEvent.kt similarity index 88% rename from shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/domain/analytic/InterviewPreparationOnboardingViewedAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/domain/analytic/InterviewPreparationOnboardingViewedAnalyticEvent.kt index 0d83dcf26a..653740522d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/domain/analytic/InterviewPreparationOnboardingViewedAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/domain/analytic/InterviewPreparationOnboardingViewedAnalyticEvent.kt @@ -17,7 +17,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRou * * @see HyperskillAnalyticEvent */ -object InterviewPreparationOnboardingViewedAnalyticsEvent : HyperskillAnalyticEvent( +object InterviewPreparationOnboardingViewedAnalyticEvent : HyperskillAnalyticEvent( HyperskillAnalyticRoute.Onboarding.InterviewPreparation, HyperskillAnalyticAction.VIEW ) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/injection/InterviewPreparationOnboardingComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/injection/InterviewPreparationOnboardingComponentImpl.kt index 7cf84ee46d..635be33095 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/injection/InterviewPreparationOnboardingComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/injection/InterviewPreparationOnboardingComponentImpl.kt @@ -13,7 +13,7 @@ internal class InterviewPreparationOnboardingComponentImpl( override fun interviewPreparationOnboardingFeature(stepRoute: StepRoute): Feature = InterviewPreparationOnboardingFeatureBuilder.build( stepRoute = stepRoute, - analyticsInteractor = appGraph.analyticComponent.analyticInteractor, + analyticInteractor = appGraph.analyticComponent.analyticInteractor, onboardingInteractor = appGraph.buildOnboardingDataComponent().onboardingInteractor, logger = appGraph.loggerComponent.logger, buildVariant = appGraph.commonComponent.buildKonfig.buildVariant diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/injection/InterviewPreparationOnboardingFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/injection/InterviewPreparationOnboardingFeatureBuilder.kt index e8ef471250..9e33eec90b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/injection/InterviewPreparationOnboardingFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/injection/InterviewPreparationOnboardingFeatureBuilder.kt @@ -21,7 +21,7 @@ internal object InterviewPreparationOnboardingFeatureBuilder { fun build( stepRoute: StepRoute, - analyticsInteractor: AnalyticInteractor, + analyticInteractor: AnalyticInteractor, onboardingInteractor: OnboardingInteractor, logger: Logger, buildVariant: BuildVariant @@ -32,7 +32,7 @@ internal object InterviewPreparationOnboardingFeatureBuilder { val actionDispatcher = InterviewPreparationOnboardingActionDispatcher( config = ActionDispatcherOptions(), - analyticInteractor = analyticsInteractor, + analyticInteractor = analyticInteractor, onboardingInteractor = onboardingInteractor ) return ReduxFeature( diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/presentation/InterviewPreparationOnboardingReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/presentation/InterviewPreparationOnboardingReducer.kt index 5ac4361708..b4e3a75a47 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/presentation/InterviewPreparationOnboardingReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/interview_preparation_onboarding/presentation/InterviewPreparationOnboardingReducer.kt @@ -1,7 +1,7 @@ package org.hyperskill.app.interview_preparation_onboarding.presentation -import org.hyperskill.app.interview_preparation_onboarding.domain.analytic.InterviewPreparationOnboardingGoToProblemClickedAnalyticsEvent -import org.hyperskill.app.interview_preparation_onboarding.domain.analytic.InterviewPreparationOnboardingViewedAnalyticsEvent +import org.hyperskill.app.interview_preparation_onboarding.domain.analytic.InterviewPreparationOnboardingGoToProblemClickedAnalyticEvent +import org.hyperskill.app.interview_preparation_onboarding.domain.analytic.InterviewPreparationOnboardingViewedAnalyticEvent import org.hyperskill.app.interview_preparation_onboarding.presentation.InterviewPreparationOnboardingFeature.Action import org.hyperskill.app.interview_preparation_onboarding.presentation.InterviewPreparationOnboardingFeature.InternalAction import org.hyperskill.app.interview_preparation_onboarding.presentation.InterviewPreparationOnboardingFeature.Message @@ -19,14 +19,14 @@ internal class InterviewPreparationOnboardingReducer : StateReducer state to setOf( InternalAction.LogAnalyticEvent( - InterviewPreparationOnboardingViewedAnalyticsEvent + InterviewPreparationOnboardingViewedAnalyticEvent ), InternalAction.MarkOnboardingAsViewed ) Message.GoToFirstProblemClicked -> state to setOf( InternalAction.LogAnalyticEvent( - InterviewPreparationOnboardingGoToProblemClickedAnalyticsEvent + InterviewPreparationOnboardingGoToProblemClickedAnalyticEvent ), Action.ViewAction.NavigateTo.StepScreen(state.stepRoute) ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/leaderboard/screen/presentation/LeaderboardScreenFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/leaderboard/screen/presentation/LeaderboardScreenFeature.kt index e5d3d615a2..e1fc1a0981 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/leaderboard/screen/presentation/LeaderboardScreenFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/leaderboard/screen/presentation/LeaderboardScreenFeature.kt @@ -37,7 +37,7 @@ object LeaderboardScreenFeature { ) : ListViewState } - enum class Tab() { + enum class Tab { DAY, WEEK } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/magic_links/domain/interactor/UrlPathProcessor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/magic_links/domain/interactor/UrlPathProcessor.kt index 40627bd46d..099c0b29ed 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/magic_links/domain/interactor/UrlPathProcessor.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/magic_links/domain/interactor/UrlPathProcessor.kt @@ -43,5 +43,6 @@ class UrlPathProcessor( is HyperskillUrlPath.ResetPassword -> false is HyperskillUrlPath.StudyPlan -> true is HyperskillUrlPath.Track -> true + is HyperskillUrlPath.Step -> true } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/cache/AppCacheDataSourceImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/cache/AppCacheDataSourceImpl.kt new file mode 100644 index 0000000000..0f72b9ccc3 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/cache/AppCacheDataSourceImpl.kt @@ -0,0 +1,15 @@ +package org.hyperskill.app.main.cache + +import com.russhwolf.settings.Settings +import org.hyperskill.app.main.data.source.AppCacheDataSource + +internal class AppCacheDataSourceImpl( + private val settings: Settings +) : AppCacheDataSource { + override fun isAppDidLaunchFirstTime(): Boolean = + settings.getBoolean(AppCacheKeyValues.APP_DID_LAUNCH_FIRST_TIME, defaultValue = false) + + override fun setAppDidLaunchFirstTime() { + settings.putBoolean(AppCacheKeyValues.APP_DID_LAUNCH_FIRST_TIME, true) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/cache/AppCacheKeyValues.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/cache/AppCacheKeyValues.kt new file mode 100644 index 0000000000..9e3ed32097 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/cache/AppCacheKeyValues.kt @@ -0,0 +1,5 @@ +package org.hyperskill.app.main.cache + +internal object AppCacheKeyValues { + const val APP_DID_LAUNCH_FIRST_TIME = "app_did_launch_first_time" +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/data/repository/AppRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/data/repository/AppRepositoryImpl.kt new file mode 100644 index 0000000000..b8295d0b73 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/data/repository/AppRepositoryImpl.kt @@ -0,0 +1,15 @@ +package org.hyperskill.app.main.data.repository + +import org.hyperskill.app.main.data.source.AppCacheDataSource +import org.hyperskill.app.main.domain.repository.AppRepository + +internal class AppRepositoryImpl( + private val appCacheDataSource: AppCacheDataSource +) : AppRepository { + override fun isAppDidLaunchFirstTime(): Boolean = + appCacheDataSource.isAppDidLaunchFirstTime() + + override fun setAppDidLaunchFirstTime() { + appCacheDataSource.setAppDidLaunchFirstTime() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/data/source/AppCacheDataSource.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/data/source/AppCacheDataSource.kt new file mode 100644 index 0000000000..a0fbfdfd7e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/data/source/AppCacheDataSource.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.main.data.source + +interface AppCacheDataSource { + fun isAppDidLaunchFirstTime(): Boolean + fun setAppDidLaunchFirstTime() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/domain/analytic/AppLaunchFirstTimeHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/domain/analytic/AppLaunchFirstTimeHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..a56ff20b7f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/domain/analytic/AppLaunchFirstTimeHyperskillAnalyticEvent.kt @@ -0,0 +1,23 @@ +package org.hyperskill.app.main.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute + +/** + * Represents first time app launch analytic event. + * + * JSON payload: + * ``` + * { + * "route": "app-launch-first-time", + * "action": "view" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object AppLaunchFirstTimeHyperskillAnalyticEvent : HyperskillAnalyticEvent( + route = HyperskillAnalyticRoute.AppLaunchFirstTime(), + action = HyperskillAnalyticAction.VIEW +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/domain/interactor/AppInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/domain/interactor/AppInteractor.kt index 224aea580b..8e3fd9dbe8 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/main/domain/interactor/AppInteractor.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/domain/interactor/AppInteractor.kt @@ -2,7 +2,12 @@ package org.hyperskill.app.main.domain.interactor import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.auth.domain.interactor.AuthInteractor +import org.hyperskill.app.core.domain.DataSourceType +import org.hyperskill.app.main.domain.analytic.AppLaunchFirstTimeHyperskillAnalyticEvent +import org.hyperskill.app.main.domain.repository.AppRepository import org.hyperskill.app.notification.remote.domain.interactor.PushNotificationsInteractor +import org.hyperskill.app.profile.domain.model.Profile +import org.hyperskill.app.profile.domain.model.isNewUser import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.progresses.domain.repository.ProgressesRepository import org.hyperskill.app.projects.domain.repository.ProjectsRepository @@ -12,6 +17,7 @@ import org.hyperskill.app.track.domain.repository.TrackRepository import org.hyperskill.app.user_storage.domain.interactor.UserStorageInteractor class AppInteractor( + private val appRepository: AppRepository, private val authInteractor: AuthInteractor, private val currentProfileStateRepository: CurrentProfileStateRepository, private val userStorageInteractor: UserStorageInteractor, @@ -40,4 +46,33 @@ class AppInteractor( projectsRepository.clearCache() shareStreakRepository.clearCache() } + + suspend fun logAppLaunchFirstTimeAnalyticEventIfNeeded() { + if (!appRepository.isAppDidLaunchFirstTime()) { + appRepository.setAppDidLaunchFirstTime() + analyticInteractor.logEvent(AppLaunchFirstTimeHyperskillAnalyticEvent) + } + } + + suspend fun fetchProfile(isAuthorized: Boolean): Result = + if (isAuthorized) { + currentProfileStateRepository + .getStateWithSource(forceUpdate = false) + .fold( + onSuccess = { (profile, usedDataSourceType) -> + /** + * ALTAPPS-693: + * If cached user is new, we need to fetch profile from remote to check if track selected + */ + if (profile.isNewUser && usedDataSourceType == DataSourceType.CACHE) { + currentProfileStateRepository.getState(forceUpdate = true) + } else { + Result.success(profile) + } + }, + onFailure = { currentProfileStateRepository.getState(forceUpdate = true) } + ) + } else { + currentProfileStateRepository.getState(forceUpdate = true) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/domain/repository/AppRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/domain/repository/AppRepository.kt new file mode 100644 index 0000000000..0b9dce37bb --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/domain/repository/AppRepository.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.main.domain.repository + +interface AppRepository { + fun isAppDidLaunchFirstTime(): Boolean + fun setAppDidLaunchFirstTime() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/injection/AppFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/injection/AppFeatureBuilder.kt index f333089632..93f29f0e42 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/main/injection/AppFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/injection/AppFeatureBuilder.kt @@ -13,14 +13,16 @@ import org.hyperskill.app.main.presentation.AppFeature.Action import org.hyperskill.app.main.presentation.AppFeature.Message import org.hyperskill.app.main.presentation.AppFeature.State import org.hyperskill.app.main.presentation.AppReducer -import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingDispatcher +import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingActionDispatcher import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingReducer import org.hyperskill.app.notification.local.domain.interactor.NotificationInteractor import org.hyperskill.app.notification.remote.domain.interactor.PushNotificationsInteractor -import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository +import org.hyperskill.app.purchases.domain.interactor.PurchaseInteractor import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.streak_recovery.presentation.StreakRecoveryActionDispatcher import org.hyperskill.app.streak_recovery.presentation.StreakRecoveryReducer +import org.hyperskill.app.subscriptions.domain.interactor.SubscriptionsInteractor +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingActionDispatcher import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingReducer import ru.nobird.app.core.model.safeCast @@ -36,36 +38,43 @@ internal object AppFeatureBuilder { initialState: State?, appInteractor: AppInteractor, authInteractor: AuthInteractor, - currentProfileStateRepository: CurrentProfileStateRepository, sentryInteractor: SentryInteractor, stateRepositoriesComponent: StateRepositoriesComponent, streakRecoveryReducer: StreakRecoveryReducer, streakRecoveryActionDispatcher: StreakRecoveryActionDispatcher, clickedNotificationReducer: NotificationClickHandlingReducer, - notificationClickHandlingDispatcher: NotificationClickHandlingDispatcher, + notificationClickHandlingActionDispatcher: NotificationClickHandlingActionDispatcher, notificationsInteractor: NotificationInteractor, pushNotificationsInteractor: PushNotificationsInteractor, welcomeOnboardingReducer: WelcomeOnboardingReducer, welcomeOnboardingActionDispatcher: WelcomeOnboardingActionDispatcher, + purchaseInteractor: PurchaseInteractor, + currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, + subscriptionsInteractor: SubscriptionsInteractor, platform: Platform, logger: Logger, buildVariant: BuildVariant ): Feature { val appReducer = AppReducer( - streakRecoveryReducer, - clickedNotificationReducer, - welcomeOnboardingReducer, + streakRecoveryReducer = streakRecoveryReducer, + notificationClickHandlingReducer = clickedNotificationReducer, + welcomeOnboardingReducer = welcomeOnboardingReducer, platformType = platform.platformType ).wrapWithLogger(buildVariant, logger, LOG_TAG) + val appActionDispatcher = AppActionDispatcher( - ActionDispatcherOptions(), - appInteractor, - authInteractor, - currentProfileStateRepository, - sentryInteractor, - stateRepositoriesComponent, - notificationsInteractor, - pushNotificationsInteractor + config = ActionDispatcherOptions(), + appInteractor = appInteractor, + authInteractor = authInteractor, + sentryInteractor = sentryInteractor, + stateRepositoriesComponent = stateRepositoriesComponent, + notificationsInteractor = notificationsInteractor, + pushNotificationsInteractor = pushNotificationsInteractor, + purchaseInteractor = purchaseInteractor, + currentSubscriptionStateRepository = currentSubscriptionStateRepository, + subscriptionsInteractor = subscriptionsInteractor, + isSubscriptionPurchaseEnabled = platform.isSubscriptionPurchaseEnabled, + logger.withTag(LOG_TAG) ) return ReduxFeature(initialState ?: State.Idle, appReducer) @@ -77,7 +86,7 @@ internal object AppFeatureBuilder { ) ) .wrapWithActionDispatcher( - notificationClickHandlingDispatcher.transform( + notificationClickHandlingActionDispatcher.transform( transformAction = { it.safeCast()?.action }, transformMessage = Message::NotificationClickHandlingMessage ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/injection/MainComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/injection/MainComponentImpl.kt index fee9010251..cfb377418c 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/main/injection/MainComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/injection/MainComponentImpl.kt @@ -17,27 +17,30 @@ internal class MainComponentImpl(private val appGraph: AppGraph) : MainComponent private val welcomeOnboardingComponent: WelcomeOnboardingComponent = appGraph.buildWelcomeOnboardingComponent() + /*ktlint-disable*/ override fun appFeature( initialState: AppFeature.State? ): Feature = AppFeatureBuilder.build( - initialState, - appGraph.buildMainDataComponent().appInteractor, - appGraph.authComponent.authInteractor, - appGraph.profileDataComponent.currentProfileStateRepository, - appGraph.sentryComponent.sentryInteractor, - appGraph.stateRepositoriesComponent, - streakRecoveryComponent.streakRecoveryReducer, - streakRecoveryComponent.streakRecoveryActionDispatcher, - clickedNotificationComponent.notificationClickHandlingReducer, - clickedNotificationComponent.notificationClickHandlingDispatcher, - appGraph.buildNotificationComponent().notificationInteractor, - appGraph.buildPushNotificationsComponent().pushNotificationsInteractor, - welcomeOnboardingComponent.welcomeOnboardingReducer, - welcomeOnboardingComponent.welcomeOnboardingActionDispatcher, - appGraph.commonComponent.platform, - appGraph.loggerComponent.logger, - appGraph.commonComponent.buildKonfig.buildVariant + initialState = initialState, + appInteractor = appGraph.buildMainDataComponent().appInteractor, + authInteractor = appGraph.authComponent.authInteractor, + sentryInteractor = appGraph.sentryComponent.sentryInteractor, + stateRepositoriesComponent = appGraph.stateRepositoriesComponent, + streakRecoveryReducer = streakRecoveryComponent.streakRecoveryReducer, + streakRecoveryActionDispatcher = streakRecoveryComponent.streakRecoveryActionDispatcher, + clickedNotificationReducer = clickedNotificationComponent.notificationClickHandlingReducer, + notificationClickHandlingActionDispatcher = clickedNotificationComponent.notificationClickHandlingActionDispatcher, + notificationsInteractor = appGraph.buildNotificationComponent().notificationInteractor, + pushNotificationsInteractor = appGraph.buildPushNotificationsComponent().pushNotificationsInteractor, + welcomeOnboardingReducer = welcomeOnboardingComponent.welcomeOnboardingReducer, + welcomeOnboardingActionDispatcher = welcomeOnboardingComponent.welcomeOnboardingActionDispatcher, + purchaseInteractor = appGraph.buildPurchaseComponent().purchaseInteractor, + currentSubscriptionStateRepository = appGraph.stateRepositoriesComponent.currentSubscriptionStateRepository, + subscriptionsInteractor = appGraph.subscriptionDataComponent.subscriptionsInteractor, + platform = appGraph.commonComponent.platform, + logger = appGraph.loggerComponent.logger, + buildVariant = appGraph.commonComponent.buildKonfig.buildVariant ) override fun appFeature(): Feature = diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/injection/MainDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/injection/MainDataComponentImpl.kt index 86039e5562..7941032c88 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/main/injection/MainDataComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/injection/MainDataComponentImpl.kt @@ -1,20 +1,31 @@ package org.hyperskill.app.main.injection import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.main.cache.AppCacheDataSourceImpl +import org.hyperskill.app.main.data.repository.AppRepositoryImpl +import org.hyperskill.app.main.data.source.AppCacheDataSource import org.hyperskill.app.main.domain.interactor.AppInteractor +import org.hyperskill.app.main.domain.repository.AppRepository internal class MainDataComponentImpl(private val appGraph: AppGraph) : MainDataComponent { + private val appCacheDataSource: AppCacheDataSource = + AppCacheDataSourceImpl(appGraph.commonComponent.settings) + + private val appRepository: AppRepository = + AppRepositoryImpl(appCacheDataSource) + override val appInteractor: AppInteractor get() = AppInteractor( - appGraph.authComponent.authInteractor, - appGraph.profileDataComponent.currentProfileStateRepository, - appGraph.buildUserStorageComponent().userStorageInteractor, - appGraph.analyticComponent.analyticInteractor, - appGraph.buildProgressesDataComponent().progressesRepository, - appGraph.buildTrackDataComponent().trackRepository, - appGraph.buildProvidersDataComponent().providersRepository, - appGraph.buildProjectsDataComponent().projectsRepository, - appGraph.buildShareStreakDataComponent().shareStreakRepository, - appGraph.buildPushNotificationsComponent().pushNotificationsInteractor + appRepository = appRepository, + authInteractor = appGraph.authComponent.authInteractor, + currentProfileStateRepository = appGraph.profileDataComponent.currentProfileStateRepository, + userStorageInteractor = appGraph.buildUserStorageComponent().userStorageInteractor, + analyticInteractor = appGraph.analyticComponent.analyticInteractor, + progressesRepository = appGraph.buildProgressesDataComponent().progressesRepository, + trackRepository = appGraph.buildTrackDataComponent().trackRepository, + providersRepository = appGraph.buildProvidersDataComponent().providersRepository, + projectsRepository = appGraph.buildProjectsDataComponent().projectsRepository, + shareStreakRepository = appGraph.buildShareStreakDataComponent().shareStreakRepository, + pushNotificationsInteractor = appGraph.buildPushNotificationsComponent().pushNotificationsInteractor ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/presentation/AppActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/presentation/AppActionDispatcher.kt index d47eee3418..8cae43187d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/main/presentation/AppActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/presentation/AppActionDispatcher.kt @@ -1,5 +1,9 @@ package org.hyperskill.app.main.presentation +import co.touchlab.kermit.Logger +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.hyperskill.app.auth.domain.interactor.AuthInteractor @@ -12,24 +16,34 @@ import org.hyperskill.app.main.presentation.AppFeature.Action import org.hyperskill.app.main.presentation.AppFeature.Message import org.hyperskill.app.notification.local.domain.interactor.NotificationInteractor import org.hyperskill.app.notification.remote.domain.interactor.PushNotificationsInteractor -import org.hyperskill.app.profile.domain.model.Profile -import org.hyperskill.app.profile.domain.model.isNewUser -import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository +import org.hyperskill.app.purchases.domain.interactor.PurchaseInteractor import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.sentry.domain.model.breadcrumb.HyperskillSentryBreadcrumbBuilder import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder +import org.hyperskill.app.sentry.domain.withTransaction +import org.hyperskill.app.subscriptions.domain.interactor.SubscriptionsInteractor +import org.hyperskill.app.subscriptions.domain.model.Subscription +import org.hyperskill.app.subscriptions.domain.model.SubscriptionType +import org.hyperskill.app.subscriptions.domain.model.isExpired +import org.hyperskill.app.subscriptions.domain.model.isValidTillPassed +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher internal class AppActionDispatcher( config: ActionDispatcherOptions, private val appInteractor: AppInteractor, private val authInteractor: AuthInteractor, - private val currentProfileStateRepository: CurrentProfileStateRepository, private val sentryInteractor: SentryInteractor, private val stateRepositoriesComponent: StateRepositoriesComponent, private val notificationsInteractor: NotificationInteractor, private val pushNotificationsInteractor: PushNotificationsInteractor, + private val purchaseInteractor: PurchaseInteractor, + private val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, + private val subscriptionsInteractor: SubscriptionsInteractor, + private val isSubscriptionPurchaseEnabled: Boolean, + private val logger: Logger ) : CoroutineActionDispatcher(config.createConfig()) { + init { authInteractor .observeUserDeauthorization() @@ -50,72 +64,116 @@ internal class AppActionDispatcher( onNewMessage(Message.UserDeauthorized(it.reason)) } .launchIn(actionScope) + + if (isSubscriptionPurchaseEnabled) { + currentSubscriptionStateRepository + .changes + .distinctUntilChanged() + .onEach { subscription -> + onNewMessage(AppFeature.InternalMessage.SubscriptionChanged(subscription)) + } + .launchIn(actionScope) + } } override suspend fun doSuspendableAction(action: Action) { when (action) { - is Action.DetermineUserAccountStatus -> { - val isAuthorized = authInteractor.isAuthorized() - .getOrDefault(false) - - val transaction = HyperskillSentryTransactionBuilder.buildAppScreenRemoteDataLoading(isAuthorized) - sentryInteractor.startTransaction(transaction) - - sentryInteractor.addBreadcrumb(HyperskillSentryBreadcrumbBuilder.buildAppDetermineUserAccountStatus()) - - // TODO: Move this logic to reducer - val profileResult: Result = if (isAuthorized) { - currentProfileStateRepository - .getStateWithSource(forceUpdate = false) - .fold( - onSuccess = { (profile, usedDataSourceType) -> - /** - * ALTAPPS-693: - * If cached user is new, we need to fetch profile from remote to check if track selected - */ - if (profile.isNewUser && usedDataSourceType == DataSourceType.CACHE) { - currentProfileStateRepository.getState(forceUpdate = true) - } else { - Result.success(profile) - } - }, - onFailure = { currentProfileStateRepository.getState(forceUpdate = true) } - ) - } else { - currentProfileStateRepository.getState(forceUpdate = true) - } - - profileResult - .fold( - onSuccess = { profile -> - sentryInteractor.addBreadcrumb( - HyperskillSentryBreadcrumbBuilder.buildAppDetermineUserAccountStatusSuccess() - ) - sentryInteractor.finishTransaction(transaction) - onNewMessage(Message.UserAccountStatus(profile, action.pushNotificationData)) - }, - onFailure = { exception -> - sentryInteractor.addBreadcrumb( - HyperskillSentryBreadcrumbBuilder.buildAppDetermineUserAccountStatusError(exception) - ) - sentryInteractor.finishTransaction(transaction, exception) - onNewMessage(Message.UserAccountStatusError) - } - ) - } + is Action.FetchAppStartupConfig -> + handleFetchAppStartupConfig(action, ::onNewMessage) is Action.IdentifyUserInSentry -> sentryInteractor.setUsedId(action.userId) is Action.ClearUserInSentry -> sentryInteractor.clearCurrentUser() is Action.UpdateDailyLearningNotificationTime -> handleUpdateDailyLearningNotificationTime() - is Action.SendPushNotificationsToken -> { + is Action.SendPushNotificationsToken -> pushNotificationsInteractor.renewFCMToken() - } + is Action.IdentifyUserInPurchaseSdk -> + handleIdentifyUserInPurchaseSdk(action.userId) + is Action.LogAppLaunchFirstTimeAnalyticEventIfNeeded -> + appInteractor.logAppLaunchFirstTimeAnalyticEventIfNeeded() + is AppFeature.InternalAction.FetchSubscription -> + handleFetchSubscription(::onNewMessage) + is AppFeature.InternalAction.RefreshSubscriptionOnExpiration -> + subscriptionsInteractor.refreshSubscriptionOnExpirationIfNeeded(action.subscription) + is AppFeature.InternalAction.CancelSubscriptionRefresh -> + subscriptionsInteractor.cancelSubscriptionRefresh() else -> {} } } + private suspend fun handleFetchAppStartupConfig( + action: Action.FetchAppStartupConfig, + onNewMessage: (Message) -> Unit + ) { + val isAuthorized = + authInteractor.isAuthorized().getOrDefault(false) + + sentryInteractor.withTransaction( + transaction = HyperskillSentryTransactionBuilder.buildAppScreenRemoteDataLoading(isAuthorized), + onError = { e -> + sentryInteractor.addBreadcrumb( + HyperskillSentryBreadcrumbBuilder.buildAppDetermineUserAccountStatusError(e) + ) + Message.FetchAppStartupConfigError + } + ) { + coroutineScope { + sentryInteractor.addBreadcrumb(HyperskillSentryBreadcrumbBuilder.buildAppDetermineUserAccountStatus()) + + val profileDeferred = async { appInteractor.fetchProfile(isAuthorized) } + val subscriptionDeferred = async { fetchSubscription(isAuthorized) } + + val profile = profileDeferred.await().getOrThrow() + val subscription = subscriptionDeferred.await() + + sentryInteractor.addBreadcrumb( + HyperskillSentryBreadcrumbBuilder.buildAppDetermineUserAccountStatusSuccess() + ) + + Message.FetchAppStartupConfigSuccess( + profile = profile, + subscription = subscription, + notificationData = action.pushNotificationData + ) + } + }.let(onNewMessage) + } + + private suspend fun fetchSubscription(isAuthorized: Boolean = true): Subscription? = + if (isAuthorized && isSubscriptionPurchaseEnabled) { + currentSubscriptionStateRepository + .getStateWithSource(forceUpdate = false) + .fold( + onSuccess = { (subscription, usedDataSourceType) -> + // Fetch subscription from remote + // if the user has the mobile-only subscription + // and its valid time is passed + val shouldFetchSubscriptionFromRemote = + usedDataSourceType == DataSourceType.CACHE && + subscription.type == SubscriptionType.MOBILE_ONLY && + (subscription.isExpired || subscription.isValidTillPassed()) + if (shouldFetchSubscriptionFromRemote) { + currentSubscriptionStateRepository + .getState(forceUpdate = true) + .getOrNull() + } else { + subscription + } + }, + onFailure = { + currentSubscriptionStateRepository + .getState(forceUpdate = true) + .onFailure { e -> + logger.e(e) { "Failed to fetch subscription" } + } + .getOrNull() + } + ) + } else { + null + } + private suspend fun handleUpdateDailyLearningNotificationTime() { notificationsInteractor .updateTimeZone() @@ -125,4 +183,24 @@ internal class AppActionDispatcher( ) } } + + private suspend fun handleIdentifyUserInPurchaseSdk(userId: Long) { + if (isSubscriptionPurchaseEnabled) { + purchaseInteractor + .login(userId) + .onFailure { + logger.e(it) { + "Failed to login user in the purchase sdk" + } + } + } + } + + private suspend fun handleFetchSubscription(onNewMessage: (Message) -> Unit) { + fetchSubscription()?.let { + onNewMessage( + AppFeature.InternalMessage.SubscriptionChanged(it) + ) + } + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/presentation/AppFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/presentation/AppFeature.kt index d0bb39a7ab..c1cdf44f99 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/main/presentation/AppFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/presentation/AppFeature.kt @@ -4,11 +4,14 @@ import kotlinx.serialization.Serializable import org.hyperskill.app.auth.domain.model.UserDeauthorized.Reason import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingFeature import org.hyperskill.app.notification.remote.domain.model.PushNotificationData +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource import org.hyperskill.app.profile.domain.model.Profile import org.hyperskill.app.streak_recovery.presentation.StreakRecoveryFeature +import org.hyperskill.app.subscriptions.domain.model.Subscription import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature -interface AppFeature { +object AppFeature { + @Serializable sealed interface State { @Serializable @@ -25,8 +28,14 @@ interface AppFeature { val isAuthorized: Boolean, val isMobileLeaderboardsEnabled: Boolean, internal val streakRecoveryState: StreakRecoveryFeature.State = StreakRecoveryFeature.State(), - internal val welcomeOnboardingState: WelcomeOnboardingFeature.State = WelcomeOnboardingFeature.State() - ) : State + internal val welcomeOnboardingState: WelcomeOnboardingFeature.State = WelcomeOnboardingFeature.State(), + internal val isMobileOnlySubscriptionEnabled: Boolean, + internal val subscription: Subscription? = null, + internal val appShowsCount: Int = 1 + ) : State { + internal fun incrementAppShowsCount(): Ready = + copy(appShowsCount = appShowsCount + 1) + } } sealed interface Message { @@ -35,11 +44,14 @@ interface AppFeature { val forceUpdate: Boolean = false ) : Message - data class UserAccountStatus( + object AppBecomesActive : Message + + data class FetchAppStartupConfigSuccess( val profile: Profile, + val subscription: Subscription?, val notificationData: PushNotificationData? ) : Message - object UserAccountStatusError : Message + object FetchAppStartupConfigError : Message data class UserAuthorized( val profile: Profile, @@ -68,8 +80,14 @@ interface AppFeature { ) : Message } + internal sealed interface InternalMessage : Message { + data class SubscriptionChanged( + val subscription: Subscription + ) : InternalMessage + } + sealed interface Action { - data class DetermineUserAccountStatus( + data class FetchAppStartupConfig( val pushNotificationData: PushNotificationData? ) : Action @@ -77,6 +95,8 @@ interface AppFeature { object SendPushNotificationsToken : Action + object LogAppLaunchFirstTimeAnalyticEventIfNeeded : Action + /** * Action Wrappers */ @@ -96,12 +116,18 @@ interface AppFeature { data class IdentifyUserInSentry(val userId: Long) : Action object ClearUserInSentry : Action + data class IdentifyUserInPurchaseSdk(val userId: Long) : Action + sealed interface ViewAction : Action { sealed interface NavigateTo : ViewAction { data class AuthScreen(val isInSignUpMode: Boolean = false) : NavigateTo object TrackSelectionScreen : NavigateTo - object OnboardingScreen : NavigateTo + object WelcomeScreen : NavigateTo object StudyPlan : NavigateTo + data class Paywall(val paywallTransitionSource: PaywallTransitionSource) : NavigateTo + data class StudyPlanWithPaywall( + val paywallTransitionSource: PaywallTransitionSource + ) : NavigateTo } /** @@ -118,4 +144,12 @@ interface AppFeature { ) : ViewAction } } + + internal sealed interface InternalAction : Action { + object FetchSubscription : InternalAction + + data class RefreshSubscriptionOnExpiration(val subscription: Subscription) : InternalAction + + object CancelSubscriptionRefresh : InternalAction + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/main/presentation/AppReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/main/presentation/AppReducer.kt index c6783b55cb..1081cdbbe2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/main/presentation/AppReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/main/presentation/AppReducer.kt @@ -3,17 +3,22 @@ package org.hyperskill.app.main.presentation import org.hyperskill.app.auth.domain.model.UserDeauthorized import org.hyperskill.app.core.domain.platform.PlatformType import org.hyperskill.app.main.presentation.AppFeature.Action +import org.hyperskill.app.main.presentation.AppFeature.InternalAction +import org.hyperskill.app.main.presentation.AppFeature.InternalMessage import org.hyperskill.app.main.presentation.AppFeature.Message import org.hyperskill.app.main.presentation.AppFeature.State import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingFeature import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingReducer +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource import org.hyperskill.app.profile.domain.model.Profile import org.hyperskill.app.profile.domain.model.isMobileLeaderboardsEnabled +import org.hyperskill.app.profile.domain.model.isMobileOnlySubscriptionEnabled import org.hyperskill.app.profile.domain.model.isNewUser import org.hyperskill.app.streak_recovery.presentation.StreakRecoveryFeature import org.hyperskill.app.streak_recovery.presentation.StreakRecoveryReducer +import org.hyperskill.app.subscriptions.domain.model.Subscription +import org.hyperskill.app.subscriptions.domain.model.isFreemium import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature -import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature.OnboardingFlowFinishReason import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingReducer import org.hyperskill.app.welcome_onboarding.presentation.getFinishAction import ru.nobird.app.presentation.redux.reducer.StateReducer @@ -26,6 +31,11 @@ internal class AppReducer( private val welcomeOnboardingReducer: WelcomeOnboardingReducer, private val platformType: PlatformType ) : StateReducer { + + companion object { + internal const val APP_SHOWS_COUNT_TILL_PAYWALL = 3 + } + override fun reduce( state: State, message: Message @@ -33,34 +43,39 @@ internal class AppReducer( when (message) { is Message.Initialize -> { if (state is State.Idle || (state is State.NetworkError && message.forceUpdate)) { - State.Loading to setOf(Action.DetermineUserAccountStatus(message.pushNotificationData)) + State.Loading to setOf( + Action.FetchAppStartupConfig(message.pushNotificationData), + Action.LogAppLaunchFirstTimeAnalyticEventIfNeeded + ) } else { null } } - is Message.UserAccountStatus -> - handleUserAccountStatus(state, message) - is Message.UserAccountStatusError -> + is Message.FetchAppStartupConfigSuccess -> + handleFetchAppStartupConfigSuccess(state, message) + is Message.FetchAppStartupConfigError -> if (state is State.Loading) { State.NetworkError to emptySet() } else { null } + is Message.AppBecomesActive -> handleAppBecomesActive(state) is Message.UserAuthorized -> handleUserAuthorized(state, message) is Message.UserDeauthorized -> if (state is State.Ready && state.isAuthorized) { val navigateToViewAction = when (message.reason) { UserDeauthorized.Reason.TOKEN_REFRESH_FAILURE -> - Action.ViewAction.NavigateTo.OnboardingScreen + Action.ViewAction.NavigateTo.WelcomeScreen UserDeauthorized.Reason.SIGN_OUT -> Action.ViewAction.NavigateTo.AuthScreen() } State.Ready( isAuthorized = false, - isMobileLeaderboardsEnabled = false - ) to setOf(Action.ClearUserInSentry, navigateToViewAction) + isMobileLeaderboardsEnabled = false, + isMobileOnlySubscriptionEnabled = false + ) to getDeauthorizedUserActions() + setOf(navigateToViewAction) } else { null } @@ -82,15 +97,36 @@ internal class AppReducer( state to reduceNotificationClickHandlingMessage(message.message) is Message.WelcomeOnboardingMessage -> reduceWelcomeOnboardingMessage(state, message.message) + is InternalMessage.SubscriptionChanged -> + handleSubscriptionChanged(state, message) } ?: (state to emptySet()) - private fun handleUserAccountStatus( + private fun handleFetchAppStartupConfigSuccess( state: State, - message: Message.UserAccountStatus + message: Message.FetchAppStartupConfigSuccess ): ReducerResult = if (state is State.Loading) { val isAuthorized = !message.profile.isGuest + val (streakRecoveryState, streakRecoveryActions) = + if (isAuthorized && message.notificationData == null) { + reduceStreakRecoveryMessage( + StreakRecoveryFeature.State(), + StreakRecoveryFeature.Message.Initialize + ) + } else { + StreakRecoveryFeature.State() to emptySet() + } + + val readyState = State.Ready( + isAuthorized = isAuthorized, + isMobileLeaderboardsEnabled = message.profile.features.isMobileLeaderboardsEnabled, + streakRecoveryState = streakRecoveryState, + appShowsCount = 0, // This is a hack to show paywall on the first app start + subscription = message.subscription, + isMobileOnlySubscriptionEnabled = message.profile.features.isMobileOnlySubscriptionEnabled + ) + val actions: Set = buildSet { if (isAuthorized) { @@ -107,10 +143,22 @@ internal class AppReducer( ) message.profile.isNewUser -> add(Action.ViewAction.NavigateTo.TrackSelectionScreen) + shouldShowPaywall(readyState) -> + add( + Action.ViewAction.NavigateTo.StudyPlanWithPaywall( + PaywallTransitionSource.APP_BECOMES_ACTIVE + ) + ) else -> add(Action.ViewAction.NavigateTo.StudyPlan) } - addAll(getOnAuthorizedAppStartUpActions(message.profile.id, platformType)) + addAll( + getOnAuthorizedAppStartUpActions( + profileId = message.profile.id, + subscription = message.subscription, + platformType = platformType + ) + ) } else { if (message.notificationData != null) { addAll( @@ -124,25 +172,12 @@ internal class AppReducer( ) } addAll(getNotAuthorizedAppStartUpActions()) - add(Action.ViewAction.NavigateTo.OnboardingScreen) + add(Action.ViewAction.NavigateTo.WelcomeScreen) } + addAll(streakRecoveryActions) } - val (streakRecoveryState, streakRecoveryActions) = - if (isAuthorized && message.notificationData == null) { - reduceStreakRecoveryMessage( - StreakRecoveryFeature.State(), - StreakRecoveryFeature.Message.Initialize - ) - } else { - StreakRecoveryFeature.State() to emptySet() - } - - State.Ready( - isAuthorized = isAuthorized, - isMobileLeaderboardsEnabled = message.profile.features.isMobileLeaderboardsEnabled, - streakRecoveryState = streakRecoveryState - ) to actions + streakRecoveryActions + readyState.incrementAppShowsCount() to actions } else { state to emptySet() } @@ -154,7 +189,8 @@ internal class AppReducer( if (state is State.Ready && !state.isAuthorized) { val authState = State.Ready( isAuthorized = true, - isMobileLeaderboardsEnabled = message.profile.features.isMobileLeaderboardsEnabled + isMobileLeaderboardsEnabled = message.profile.features.isMobileLeaderboardsEnabled, + isMobileOnlySubscriptionEnabled = message.profile.features.isMobileOnlySubscriptionEnabled ) val (onboardingState, onboardingActions) = reduceWelcomeOnboardingMessage( WelcomeOnboardingFeature.State(), @@ -169,6 +205,26 @@ internal class AppReducer( state to emptySet() } + private fun handleAppBecomesActive(state: State): ReducerResult = + if (state is State.Ready) { + state.incrementAppShowsCount() to + if (shouldShowPaywall(state)) { + setOf( + Action.ViewAction.NavigateTo.Paywall(PaywallTransitionSource.APP_BECOMES_ACTIVE) + ) + } else { + emptySet() + } + } else { + state to emptySet() + } + + private fun shouldShowPaywall(state: State.Ready): Boolean = + state.isAuthorized && + state.isMobileOnlySubscriptionEnabled && + state.subscription?.isFreemium == true && + state.appShowsCount % APP_SHOWS_COUNT_TILL_PAYWALL == 0 + private fun reduceStreakRecoveryMessage( state: StreakRecoveryFeature.State, message: StreakRecoveryFeature.Message @@ -255,24 +311,22 @@ internal class AppReducer( finishAction: WelcomeOnboardingFeature.Action.OnboardingFlowFinished ): Set = setOf( - when (val reason = finishAction.reason) { - is OnboardingFlowFinishReason.NotificationOnboardingFinished -> - if (reason.profile?.isNewUser == true) { - Action.ViewAction.NavigateTo.TrackSelectionScreen - } else { - Action.ViewAction.NavigateTo.StudyPlan - } - OnboardingFlowFinishReason.FirstProblemOnboardingFinished -> - Action.ViewAction.NavigateTo.StudyPlan + if (finishAction.profile?.isNewUser == true) { + Action.ViewAction.NavigateTo.TrackSelectionScreen + } else { + Action.ViewAction.NavigateTo.StudyPlan } ) private fun getOnAuthorizedAppStartUpActions( profileId: Long, + subscription: Subscription?, platformType: PlatformType ): Set = setOfNotNull( Action.IdentifyUserInSentry(userId = profileId), + Action.IdentifyUserInPurchaseSdk(userId = profileId), + subscription?.let(InternalAction::RefreshSubscriptionOnExpiration), Action.UpdateDailyLearningNotificationTime, if (platformType == PlatformType.ANDROID) { // Don't send push token on app startup for IOS @@ -288,8 +342,28 @@ internal class AppReducer( private fun getAuthorizedUserActions(profile: Profile): Set = setOf( + InternalAction.FetchSubscription, Action.IdentifyUserInSentry(userId = profile.id), + Action.IdentifyUserInPurchaseSdk(userId = profile.id), Action.UpdateDailyLearningNotificationTime, Action.SendPushNotificationsToken ) + + private fun getDeauthorizedUserActions(): Set = + setOf( + Action.ClearUserInSentry, + InternalAction.CancelSubscriptionRefresh + ) + + private fun handleSubscriptionChanged( + state: State, + message: InternalMessage.SubscriptionChanged + ): ReducerResult = + if (state is State.Ready) { + state.copy(subscription = message.subscription) to setOf( + InternalAction.RefreshSubscriptionOnExpiration(message.subscription) + ) + } else { + state to emptySet() + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/domain/analytic/ManageSubscriptionClickedManageHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/domain/analytic/ManageSubscriptionClickedManageHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..3e1b840822 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/domain/analytic/ManageSubscriptionClickedManageHyperskillAnalyticEvent.kt @@ -0,0 +1,29 @@ +package org.hyperskill.app.manage_subscription.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents a click analytic event of the manage subscription button. + * + * JSON payload: + * ``` + * { + * "route": "/profile/settings/manage-subscription", + * "action": "click", + * "part": "main", + * "target": "manage_subscription" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object ManageSubscriptionClickedManageHyperskillAnalyticEvent : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Profile.Settings.ManageSubscription, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.MANAGE_SUBSCRIPTION +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/domain/analytic/ManageSubscriptionViewedHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/domain/analytic/ManageSubscriptionViewedHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..6880bab27b --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/domain/analytic/ManageSubscriptionViewedHyperskillAnalyticEvent.kt @@ -0,0 +1,23 @@ +package org.hyperskill.app.manage_subscription.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute + +/** + * Represents a view analytic event. + * + * JSON payload: + * ``` + * { + * "route": "/profile/settings/manage-subscription", + * "action": "view" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object ManageSubscriptionViewedHyperskillAnalyticEvent : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Profile.Settings.ManageSubscription, + HyperskillAnalyticAction.VIEW +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/domain/analytic/RenewSubscriptionClickedManageHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/domain/analytic/RenewSubscriptionClickedManageHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..4ee130cf3a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/domain/analytic/RenewSubscriptionClickedManageHyperskillAnalyticEvent.kt @@ -0,0 +1,29 @@ +package org.hyperskill.app.manage_subscription.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents a click analytic event of the manage subscription button. + * + * JSON payload: + * ``` + * { + * "route": "/profile/settings/manage-subscription", + * "action": "click", + * "part": "main", + * "target": "renew_subscription" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object RenewSubscriptionClickedManageHyperskillAnalyticEvent : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Profile.Settings.ManageSubscription, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.RENEW_SUBSCRIPTION +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/injection/ManageSubscriptionComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/injection/ManageSubscriptionComponent.kt new file mode 100644 index 0000000000..51e5966619 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/injection/ManageSubscriptionComponent.kt @@ -0,0 +1,10 @@ +package org.hyperskill.app.manage_subscription.injection + +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Action +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Message +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.ViewState +import ru.nobird.app.presentation.redux.feature.Feature + +interface ManageSubscriptionComponent { + val manageSubscriptionFeature: Feature +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/injection/ManageSubscriptionComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/injection/ManageSubscriptionComponentImpl.kt new file mode 100644 index 0000000000..fa7dec45df --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/injection/ManageSubscriptionComponentImpl.kt @@ -0,0 +1,23 @@ +package org.hyperskill.app.manage_subscription.injection + +import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Action +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Message +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.ViewState +import ru.nobird.app.presentation.redux.feature.Feature + +internal class ManageSubscriptionComponentImpl( + private val appGraph: AppGraph +) : ManageSubscriptionComponent { + override val manageSubscriptionFeature: Feature + get() = ManageSubscriptionFeatureBuilder.build( + analyticInteractor = appGraph.analyticComponent.analyticInteractor, + currentSubscriptionStateRepository = appGraph.stateRepositoriesComponent.currentSubscriptionStateRepository, + purchaseInteractor = appGraph.buildPurchaseComponent().purchaseInteractor, + sentryInteractor = appGraph.sentryComponent.sentryInteractor, + resourceProvider = appGraph.commonComponent.resourceProvider, + dateFormatter = appGraph.commonComponent.dateFormatter, + logger = appGraph.loggerComponent.logger, + buildVariant = appGraph.commonComponent.buildKonfig.buildVariant + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/injection/ManageSubscriptionFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/injection/ManageSubscriptionFeatureBuilder.kt new file mode 100644 index 0000000000..31c6c5d9d6 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/injection/ManageSubscriptionFeatureBuilder.kt @@ -0,0 +1,60 @@ +package org.hyperskill.app.manage_subscription.injection + +import co.touchlab.kermit.Logger +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.core.domain.BuildVariant +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.core.presentation.transformState +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter +import org.hyperskill.app.logging.presentation.wrapWithLogger +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionActionDispatcher +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Action +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Message +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.State +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.ViewState +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionReducer +import org.hyperskill.app.manage_subscription.view.mapper.ManageSubscriptionViewStateMapper +import org.hyperskill.app.purchases.domain.interactor.PurchaseInteractor +import org.hyperskill.app.sentry.domain.interactor.SentryInteractor +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository +import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher +import ru.nobird.app.presentation.redux.feature.Feature +import ru.nobird.app.presentation.redux.feature.ReduxFeature + +internal object ManageSubscriptionFeatureBuilder { + private const val LOG_TAG = "ManageSubscriptionFeature" + + fun build( + analyticInteractor: AnalyticInteractor, + currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, + purchaseInteractor: PurchaseInteractor, + sentryInteractor: SentryInteractor, + resourceProvider: ResourceProvider, + dateFormatter: SharedDateFormatter, + logger: Logger, + buildVariant: BuildVariant + ): Feature { + val manageSubscriptionReducer = + ManageSubscriptionReducer() + .wrapWithLogger(buildVariant, logger, LOG_TAG) + + val manageSubscriptionActionDispatcher = ManageSubscriptionActionDispatcher( + config = ActionDispatcherOptions(), + analyticInteractor = analyticInteractor, + currentSubscriptionStateRepository = currentSubscriptionStateRepository, + purchaseInteractor = purchaseInteractor, + sentryInteractor = sentryInteractor, + logger = logger.withTag(LOG_TAG) + ) + + val viewStateMapper = ManageSubscriptionViewStateMapper(resourceProvider, dateFormatter) + + return ReduxFeature( + initialState = State.Idle, + reducer = manageSubscriptionReducer + ) + .wrapWithActionDispatcher(manageSubscriptionActionDispatcher) + .transformState(viewStateMapper::map) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionActionDispatcher.kt new file mode 100644 index 0000000000..f7d72db916 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionActionDispatcher.kt @@ -0,0 +1,86 @@ +package org.hyperskill.app.manage_subscription.presentation + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Action +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.InternalAction +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.InternalMessage +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Message +import org.hyperskill.app.purchases.domain.interactor.PurchaseInteractor +import org.hyperskill.app.sentry.domain.interactor.SentryInteractor +import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder +import org.hyperskill.app.sentry.domain.withTransaction +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository +import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher + +internal class ManageSubscriptionActionDispatcher( + config: ActionDispatcherOptions, + private val analyticInteractor: AnalyticInteractor, + private val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, + private val purchaseInteractor: PurchaseInteractor, + private val sentryInteractor: SentryInteractor, + private val logger: Logger +) : CoroutineActionDispatcher(config.createConfig()) { + + init { + currentSubscriptionStateRepository + .changes + .distinctUntilChanged() + .onEach { subscription -> + onNewMessage( + InternalMessage.SubscriptionChanged( + subscription = subscription, + manageSubscriptionUrl = purchaseInteractor.getManagementUrl().getOrNull() + ) + ) + } + .launchIn(actionScope) + } + + override suspend fun doSuspendableAction(action: Action) { + when (action) { + is InternalAction.FetchSubscription -> + handleFetchSubscription(::onNewMessage) + is InternalAction.LogAnalyticEvent -> + analyticInteractor.logEvent(action.analyticEvent) + else -> { + // no op + } + } + } + + private suspend fun handleFetchSubscription( + onNewMessage: (Message) -> Unit + ) { + sentryInteractor.withTransaction( + transaction = HyperskillSentryTransactionBuilder.buildManageSubscriptionFeatureFetchSubscription(), + onError = { + InternalMessage.FetchSubscriptionError + } + ) { + coroutineScope { + val subscriptionDeferred = async { + currentSubscriptionStateRepository + .getState(forceUpdate = true) + .onFailure { + logger.e(it) { "Failed to load subscription" } + } + } + val manageSubscriptionUrlDeferred = async { + purchaseInteractor.getManagementUrl() + } + + InternalMessage.FetchSubscriptionSuccess( + subscription = subscriptionDeferred.await().getOrThrow(), + manageSubscriptionUrl = manageSubscriptionUrlDeferred.await().getOrThrow() + ) + } + }.let(onNewMessage) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionFeature.kt new file mode 100644 index 0000000000..4697acc298 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionFeature.kt @@ -0,0 +1,71 @@ +package org.hyperskill.app.manage_subscription.presentation + +import org.hyperskill.app.analytic.domain.model.AnalyticEvent +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.subscriptions.domain.model.Subscription + +object ManageSubscriptionFeature { + internal sealed interface State { + object Idle : State + + object Loading : State + + object Error : State + + data class Content( + val subscription: Subscription, + val manageSubscriptionUrl: String? + ) : State + } + + sealed interface ViewState { + object Idle : ViewState + + object Loading : ViewState + + object Error : ViewState + + data class Content( + val validUntilFormatted: String?, + val buttonText: String? + ) : ViewState + } + + sealed interface Message { + object Initialize : Message + object RetryContentLoading : Message + + object ActionButtonClicked : Message + + object ViewedEventMessage : Message + } + + internal sealed interface InternalMessage : Message { + object FetchSubscriptionError : InternalMessage + data class FetchSubscriptionSuccess( + val subscription: Subscription, + val manageSubscriptionUrl: String? + ) : InternalMessage + + data class SubscriptionChanged( + val subscription: Subscription, + val manageSubscriptionUrl: String? + ) : InternalMessage + } + + sealed interface Action { + sealed interface ViewAction : Action { + data class OpenUrl(val url: String) : ViewAction + + sealed interface NavigateTo : ViewAction { + data class Paywall(val paywallTransitionSource: PaywallTransitionSource) : NavigateTo + } + } + } + + internal sealed interface InternalAction : Action { + object FetchSubscription : InternalAction + + data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionReducer.kt new file mode 100644 index 0000000000..a407910376 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionReducer.kt @@ -0,0 +1,86 @@ +package org.hyperskill.app.manage_subscription.presentation + +import org.hyperskill.app.manage_subscription.domain.analytic.ManageSubscriptionClickedManageHyperskillAnalyticEvent +import org.hyperskill.app.manage_subscription.domain.analytic.ManageSubscriptionViewedHyperskillAnalyticEvent +import org.hyperskill.app.manage_subscription.domain.analytic.RenewSubscriptionClickedManageHyperskillAnalyticEvent +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Action +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.InternalAction +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.InternalMessage +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.Message +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.State +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.subscriptions.domain.model.isExpired +import ru.nobird.app.presentation.redux.reducer.StateReducer + +private typealias ReducerResult = Pair> + +internal class ManageSubscriptionReducer : StateReducer { + override fun reduce(state: State, message: Message): ReducerResult = + when (message) { + Message.Initialize -> handleInitialize(state) + InternalMessage.FetchSubscriptionError -> handleFetchSubscriptionError() + is InternalMessage.FetchSubscriptionSuccess -> handleFetchSubscriptionSuccess(message) + Message.RetryContentLoading -> fetchSubscription() + Message.ActionButtonClicked -> handleActionButtonClicked(state) + is InternalMessage.SubscriptionChanged -> handleSubscriptionChanged(state, message) + Message.ViewedEventMessage -> handleViewedEventMessage(state) + } + + private fun handleInitialize(state: State): ReducerResult = + if (state is State.Idle) { + fetchSubscription() + } else { + state to emptySet() + } + + private fun fetchSubscription(): ReducerResult = + State.Loading to setOf(InternalAction.FetchSubscription) + + private fun handleFetchSubscriptionError(): ReducerResult = + State.Error to emptySet() + + private fun handleFetchSubscriptionSuccess( + message: InternalMessage.FetchSubscriptionSuccess + ): ReducerResult = + State.Content( + subscription = message.subscription, + manageSubscriptionUrl = message.manageSubscriptionUrl + ) to emptySet() + + private fun handleActionButtonClicked(state: State): ReducerResult = + if (state is State.Content) { + state to when { + state.isSubscriptionManagementEnabled -> { + setOfNotNull( + InternalAction.LogAnalyticEvent(ManageSubscriptionClickedManageHyperskillAnalyticEvent), + state.manageSubscriptionUrl?.let(Action.ViewAction::OpenUrl) + ) + } + state.subscription.isExpired -> { + setOf( + InternalAction.LogAnalyticEvent(RenewSubscriptionClickedManageHyperskillAnalyticEvent), + Action.ViewAction.NavigateTo.Paywall(PaywallTransitionSource.MANAGE_SUBSCRIPTION) + ) + } + else -> emptySet() + } + } else { + state to emptySet() + } + + private fun handleSubscriptionChanged( + state: State, + message: InternalMessage.SubscriptionChanged + ): ReducerResult = + if (state is State.Content) { + State.Content( + subscription = message.subscription, + manageSubscriptionUrl = message.manageSubscriptionUrl + ) to setOf() + } else { + state to emptySet() + } + + private fun handleViewedEventMessage(state: State): ReducerResult = + state to setOf(InternalAction.LogAnalyticEvent(ManageSubscriptionViewedHyperskillAnalyticEvent)) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionStateExtensions.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionStateExtensions.kt new file mode 100644 index 0000000000..6ae06be721 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/presentation/ManageSubscriptionStateExtensions.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.manage_subscription.presentation + +import org.hyperskill.app.subscriptions.domain.model.isActive + +internal val ManageSubscriptionFeature.State.Content.isSubscriptionManagementEnabled: Boolean + get() = subscription.isActive && manageSubscriptionUrl != null \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/view/mapper/ManageSubscriptionViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/view/mapper/ManageSubscriptionViewStateMapper.kt new file mode 100644 index 0000000000..36a604a213 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/manage_subscription/view/mapper/ManageSubscriptionViewStateMapper.kt @@ -0,0 +1,43 @@ +package org.hyperskill.app.manage_subscription.view.mapper + +import kotlinx.datetime.Instant +import org.hyperskill.app.SharedResources +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.State +import org.hyperskill.app.manage_subscription.presentation.ManageSubscriptionFeature.ViewState +import org.hyperskill.app.manage_subscription.presentation.isSubscriptionManagementEnabled +import org.hyperskill.app.subscriptions.domain.model.isExpired + +internal class ManageSubscriptionViewStateMapper( + private val resourceProvider: ResourceProvider, + private val dateFormatter: SharedDateFormatter +) { + fun map(state: State): ViewState = + when (state) { + State.Idle -> ViewState.Idle + State.Loading -> ViewState.Loading + State.Error -> ViewState.Error + is State.Content -> mapContent(state) + } + + private fun mapContent(content: State.Content): ViewState.Content = + ViewState.Content( + validUntilFormatted = content.subscription.validTill?.let(::formatValidUntil), + buttonText = when { + content.isSubscriptionManagementEnabled -> + resourceProvider.getString(SharedResources.strings.manage_subscription_manage_btn) + content.subscription.isExpired -> + resourceProvider.getString(SharedResources.strings.manage_subscription_renew_btn) + else -> null + } + ) + + private fun formatValidUntil(validUntil: Instant): String { + val formattedDate: String = dateFormatter.formatSubscriptionValidUntil(validUntil) + return resourceProvider.getString( + SharedResources.strings.manage_subscription_valid_until, + formattedDate + ) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/injection/NotificationClickHandlingComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/injection/NotificationClickHandlingComponent.kt index 2939620b2f..59c006a8f2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/injection/NotificationClickHandlingComponent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/injection/NotificationClickHandlingComponent.kt @@ -1,9 +1,9 @@ package org.hyperskill.app.notification.click_handling.injection -import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingDispatcher +import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingActionDispatcher import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingReducer interface NotificationClickHandlingComponent { val notificationClickHandlingReducer: NotificationClickHandlingReducer - val notificationClickHandlingDispatcher: NotificationClickHandlingDispatcher + val notificationClickHandlingActionDispatcher: NotificationClickHandlingActionDispatcher } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/injection/NotificationClickHandlingComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/injection/NotificationClickHandlingComponentImpl.kt index 9ce1634478..8b0f191c40 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/injection/NotificationClickHandlingComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/injection/NotificationClickHandlingComponentImpl.kt @@ -2,18 +2,23 @@ package org.hyperskill.app.notification.click_handling.injection import org.hyperskill.app.core.injection.AppGraph import org.hyperskill.app.core.presentation.ActionDispatcherOptions -import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingDispatcher +import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingActionDispatcher import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingReducer -class NotificationClickHandlingComponentImpl(private val appGraph: AppGraph) : NotificationClickHandlingComponent { +internal class NotificationClickHandlingComponentImpl( + private val appGraph: AppGraph +) : NotificationClickHandlingComponent { override val notificationClickHandlingReducer: NotificationClickHandlingReducer get() = NotificationClickHandlingReducer() - override val notificationClickHandlingDispatcher: NotificationClickHandlingDispatcher - get() = NotificationClickHandlingDispatcher( + + override val notificationClickHandlingActionDispatcher: NotificationClickHandlingActionDispatcher + get() = NotificationClickHandlingActionDispatcher( ActionDispatcherOptions(), - appGraph.analyticComponent.analyticInteractor, - appGraph.profileDataComponent.currentProfileStateRepository, - appGraph.buildBadgesDataComponent().badgesRepository, - appGraph.sentryComponent.sentryInteractor + analyticInteractor = appGraph.analyticComponent.analyticInteractor, + currentProfileStateRepository = appGraph.profileDataComponent.currentProfileStateRepository, + nextLearningActivityStateRepository = appGraph.stateRepositoriesComponent + .nextLearningActivityStateRepository, + badgesRepository = appGraph.buildBadgesDataComponent().badgesRepository, + logger = appGraph.loggerComponent.logger ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingActionDispatcher.kt similarity index 52% rename from shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingDispatcher.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingActionDispatcher.kt index 234feb0407..62de7281ad 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingActionDispatcher.kt @@ -1,32 +1,42 @@ package org.hyperskill.app.notification.click_handling.presentation +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.badges.domain.repository.BadgesRepository import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.learning_activities.domain.repository.NextLearningActivityStateRepository import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingFeature.Action import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingFeature.Message import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository -import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher -class NotificationClickHandlingDispatcher( - scopeConfig: ActionDispatcherOptions, +class NotificationClickHandlingActionDispatcher( + config: ActionDispatcherOptions, private val analyticInteractor: AnalyticInteractor, private val currentProfileStateRepository: CurrentProfileStateRepository, + private val nextLearningActivityStateRepository: NextLearningActivityStateRepository, private val badgesRepository: BadgesRepository, - private val sentryInteractor: SentryInteractor -) : CoroutineActionDispatcher(scopeConfig.createConfig()) { + private val logger: Logger +) : CoroutineActionDispatcher(config.createConfig()) { + companion object { + private const val LOG_TAG = "NotificationClickHandlingDispatcher" + } + override suspend fun doSuspendableAction(action: Action) { when (action) { is NotificationClickHandlingFeature.InternalAction.LogAnalyticEvent -> - analyticInteractor.logEvent(action.event) + analyticInteractor.logEvent(action.analyticEvent) is NotificationClickHandlingFeature.InternalAction.FetchProfile -> { val profile = currentProfileStateRepository .getState() .getOrElse { - sentryInteractor.captureErrorMessage( - "NotificationClickHandlingDispatcher: can't fetch profile\n$it" + logger.log( + severity = Severity.Error, + tag = LOG_TAG, + throwable = null, + message = "can't fetch profile\n$it" ) onNewMessage(NotificationClickHandlingFeature.ProfileFetchResult.Error) return @@ -37,15 +47,32 @@ class NotificationClickHandlingDispatcher( val badge = badgesRepository .getBadge(action.badgeId) .getOrElse { - sentryInteractor.captureErrorMessage( - "NotificationClickHandlingDispatcher: can't fetch badge\n$it" + logger.log( + severity = Severity.Error, + tag = LOG_TAG, + throwable = null, + message = "can't fetch badge\n$it" ) onNewMessage(NotificationClickHandlingFeature.EarnedBadgeFetchResult.Error) return } - onNewMessage(NotificationClickHandlingFeature.EarnedBadgeFetchResult.Success(badge)) } + is NotificationClickHandlingFeature.InternalAction.FetchNextLearningActivity -> { + val activity = nextLearningActivityStateRepository + .getState(forceUpdate = true) + .getOrElse { + logger.log( + severity = Severity.Error, + tag = LOG_TAG, + throwable = null, + message = "can't fetch next learning activity\n$it" + ) + onNewMessage(NotificationClickHandlingFeature.NextLearningActivityFetchResult.Error) + return + } + onNewMessage(NotificationClickHandlingFeature.NextLearningActivityFetchResult.Success(activity)) + } else -> { // no op } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingFeature.kt index eeafaa3aab..a27d66fea0 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingFeature.kt @@ -1,8 +1,9 @@ package org.hyperskill.app.notification.click_handling.presentation -import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.AnalyticEvent import org.hyperskill.app.badges.domain.model.Badge import org.hyperskill.app.badges.domain.model.BadgeKind +import org.hyperskill.app.learning_activities.domain.model.LearningActivity import org.hyperskill.app.notification.remote.domain.model.PushNotificationData import org.hyperskill.app.profile.domain.model.Profile import org.hyperskill.app.step.domain.model.StepRoute @@ -13,7 +14,7 @@ object NotificationClickHandlingFeature { sealed interface Message { /** - * If [isUserAuthorized] == false, then just logs analytics event. + * If [isUserAuthorized] == false, then just logs analytic event. * Otherwise, also executes navigation to the appropriate for the [notificationData] screen. */ data class NotificationClicked( @@ -39,6 +40,11 @@ object NotificationClickHandlingFeature { object Error : EarnedBadgeFetchResult } + internal sealed interface NextLearningActivityFetchResult : Message { + data class Success(val learningActivity: LearningActivity?) : NextLearningActivityFetchResult + object Error : NextLearningActivityFetchResult + } + sealed interface Action { sealed interface ViewAction : Action { @@ -59,10 +65,12 @@ object NotificationClickHandlingFeature { } internal interface InternalAction : Action { - data class LogAnalyticEvent(val event: HyperskillAnalyticEvent) : InternalAction + data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction object FetchProfile : InternalAction data class FetchEarnedBadge(val badgeId: Long) : InternalAction + + object FetchNextLearningActivity : InternalAction } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingReducer.kt index 57dfad4e21..30c843ccc7 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/click_handling/presentation/NotificationClickHandlingReducer.kt @@ -1,14 +1,18 @@ package org.hyperskill.app.notification.click_handling.presentation +import org.hyperskill.app.learning_activities.domain.model.LearningActivity +import org.hyperskill.app.learning_activities.presentation.mapper.LearningActivityTargetViewActionMapper +import org.hyperskill.app.learning_activities.presentation.model.LearningActivityTargetViewAction import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingFeature.Action import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingFeature.EarnedBadgeFetchResult import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingFeature.InternalAction import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingFeature.Message +import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingFeature.NextLearningActivityFetchResult import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingFeature.ProfileFetchResult import org.hyperskill.app.notification.click_handling.presentation.NotificationClickHandlingFeature.State import org.hyperskill.app.notification.remote.domain.analytic.PushNotificationClickedHyperskillAnalyticEvent import org.hyperskill.app.notification.remote.domain.model.PushNotificationType -import org.hyperskill.app.profile.domain.analytic.badges.EarnedBadgeModalHiddenHyperskillAnalyticsEvent +import org.hyperskill.app.profile.domain.analytic.badges.EarnedBadgeModalHiddenHyperskillAnalyticEvent import org.hyperskill.app.profile.domain.analytic.badges.EarnedBadgeModalShownHyperskillAnalyticEvent import org.hyperskill.app.step.domain.model.StepRoute import ru.nobird.app.presentation.redux.reducer.StateReducer @@ -21,12 +25,13 @@ class NotificationClickHandlingReducer : StateReducer { is Message.NotificationClicked -> handleNotificationClicked(message) is ProfileFetchResult -> handleProfileFetchResult(message) is EarnedBadgeFetchResult -> handleEarnedBadgeFetchResult(message) + is NextLearningActivityFetchResult -> handleNextLearningActivityFetchResult(message) /** * Analytic */ is Message.EarnedBadgeModalHiddenEventMessage -> setOf( - InternalAction.LogAnalyticEvent(EarnedBadgeModalHiddenHyperskillAnalyticsEvent(message.badgeKind)) + InternalAction.LogAnalyticEvent(EarnedBadgeModalHiddenHyperskillAnalyticEvent(message.badgeKind)) ) is Message.EarnedBadgeModalShownEventMessage -> setOf( @@ -37,10 +42,10 @@ class NotificationClickHandlingReducer : StateReducer { private fun handleNotificationClicked( message: Message.NotificationClicked ): Set { - val analyticsAction = InternalAction.LogAnalyticEvent( + val logAnalyticEventAction = InternalAction.LogAnalyticEvent( PushNotificationClickedHyperskillAnalyticEvent(message.notificationData) ) - if (!message.isUserAuthorized) return setOf(analyticsAction) + if (!message.isUserAuthorized) return setOf(logAnalyticEventAction) val actions = when (message.notificationData.typeEnum) { PushNotificationType.STREAK_THREE, @@ -48,13 +53,18 @@ class NotificationClickHandlingReducer : StateReducer { PushNotificationType.STREAK_RECORD_START, PushNotificationType.STREAK_RECORD_NEAR, PushNotificationType.STREAK_RECORD_COMPLETE, - PushNotificationType.DAILY_REMINDER, PushNotificationType.STREAK_NEW -> setOf( Action.ViewAction.SetLoadingShowed(true), InternalAction.FetchProfile ) + PushNotificationType.DAILY_REMINDER -> + setOf( + Action.ViewAction.SetLoadingShowed(true), + InternalAction.FetchNextLearningActivity + ) + PushNotificationType.LEARN_TOPIC, PushNotificationType.REMIND_SHORT -> setOf(Action.ViewAction.NavigateTo.StudyPlan) @@ -87,7 +97,7 @@ class NotificationClickHandlingReducer : StateReducer { setOf(Action.ViewAction.NavigateTo.StudyPlan) } - return actions + analyticsAction + return actions + logAnalyticEventAction } private fun handleProfileFetchResult( @@ -119,4 +129,42 @@ class NotificationClickHandlingReducer : StateReducer { EarnedBadgeFetchResult.Error -> null } ) + + private fun handleNextLearningActivityFetchResult( + message: NextLearningActivityFetchResult + ): Set = + setOfNotNull( + Action.ViewAction.SetLoadingShowed(false), + when (message) { + is NextLearningActivityFetchResult.Success -> { + val stepRoute = getStepRouteForNextLearningActivity(message.learningActivity) + if (stepRoute != null) { + Action.ViewAction.NavigateTo.StepScreen(stepRoute) + } else { + Action.ViewAction.NavigateTo.StudyPlan + } + } + NextLearningActivityFetchResult.Error -> Action.ViewAction.NavigateTo.StudyPlan + } + ) + + private fun getStepRouteForNextLearningActivity(learningActivity: LearningActivity?): StepRoute? { + if (learningActivity == null) { + return null + } + + val learningActivityTargetViewAction = LearningActivityTargetViewActionMapper + .mapLearningActivityToTargetViewAction( + activity = learningActivity, + trackId = null, + projectId = null + ) + .getOrElse { return null } + + return if (learningActivityTargetViewAction is LearningActivityTargetViewAction.NavigateTo.Step) { + learningActivityTargetViewAction.stepRoute + } else { + null + } + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationDailyStudyReminderClickedHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationDailyStudyReminderClickedHyperskillAnalyticEvent.kt index 3fe73d7185..a0657ab57c 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationDailyStudyReminderClickedHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationDailyStudyReminderClickedHyperskillAnalyticEvent.kt @@ -14,7 +14,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * JSON payload: * ``` * { - * "route": "/home", + * "route": "None", * "action": "click", * "part": "notification", * "target": "daily_notification", @@ -29,7 +29,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar class NotificationDailyStudyReminderClickedHyperskillAnalyticEvent( private val notificationId: Int ) : HyperskillAnalyticEvent( - HyperskillAnalyticRoute.Home(), + HyperskillAnalyticRoute.None, HyperskillAnalyticAction.CLICK, HyperskillAnalyticPart.NOTIFICATION, HyperskillAnalyticTarget.DAILY_NOTIFICATION diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationDailyStudyReminderShownHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationDailyStudyReminderShownHyperskillAnalyticEvent.kt index 7e26cf3713..1e1eb486b6 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationDailyStudyReminderShownHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationDailyStudyReminderShownHyperskillAnalyticEvent.kt @@ -14,7 +14,7 @@ import ru.nobird.app.core.model.mapOfNotNull * JSON payload: * ``` * { - * "route": "/home", + * "route": "None", * "action": "shown", * "part": "notification", * "target": "daily_notification", @@ -28,11 +28,10 @@ import ru.nobird.app.core.model.mapOfNotNull * @see HyperskillAnalyticEvent */ class NotificationDailyStudyReminderShownHyperskillAnalyticEvent( - route: HyperskillAnalyticRoute, private val notificationId: Int, private val plannedAtISO8601: String? ) : HyperskillAnalyticEvent( - route, + HyperskillAnalyticRoute.None, HyperskillAnalyticAction.SHOWN, HyperskillAnalyticPart.NOTIFICATION, HyperskillAnalyticTarget.DAILY_NOTIFICATION diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationSystemNoticeHiddenHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationSystemNoticeHiddenHyperskillAnalyticEvent.kt index b3d3a2ecf1..9fb7903eb6 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationSystemNoticeHiddenHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationSystemNoticeHiddenHyperskillAnalyticEvent.kt @@ -13,7 +13,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * JSON payload: * ``` * { - * "route": "/home", + * "route": "/onboarding/notifications" | "None", * "action": "hidden", * "part": "notifications_system_notice", * "target": "allow / deny" @@ -22,7 +22,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * @see HyperskillAnalyticEvent */ class NotificationSystemNoticeHiddenHyperskillAnalyticEvent( - route: HyperskillAnalyticRoute, + route: HyperskillAnalyticRoute = HyperskillAnalyticRoute.None, isAllowed: Boolean ) : HyperskillAnalyticEvent( route, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationSystemNoticeShownHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationSystemNoticeShownHyperskillAnalyticEvent.kt index 58cde0476c..d27d3ee178 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationSystemNoticeShownHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/local/domain/analytic/NotificationSystemNoticeShownHyperskillAnalyticEvent.kt @@ -13,7 +13,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * JSON payload: * ``` * { - * "route": "/home", + * "route": "/onboarding/notifications" | "None", * "action": "shown", * "part": "notice", * "target": "notifications_system_notice" @@ -22,7 +22,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * @see HyperskillAnalyticEvent */ class NotificationSystemNoticeShownHyperskillAnalyticEvent( - route: HyperskillAnalyticRoute + route: HyperskillAnalyticRoute = HyperskillAnalyticRoute.None ) : HyperskillAnalyticEvent( route, HyperskillAnalyticAction.SHOWN, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/remote/domain/analytic/PushNotificationClickedHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/remote/domain/analytic/PushNotificationClickedHyperskillAnalyticEvent.kt index 4d5377b494..bb678d82d5 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/remote/domain/analytic/PushNotificationClickedHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/remote/domain/analytic/PushNotificationClickedHyperskillAnalyticEvent.kt @@ -14,7 +14,7 @@ import org.hyperskill.app.notification.remote.domain.model.PushNotificationData * JSON payload: * ``` * { - * "route": "/home", + * "route": "None", * "action": "click", * "part": "notification", * "context": @@ -32,7 +32,7 @@ import org.hyperskill.app.notification.remote.domain.model.PushNotificationData class PushNotificationClickedHyperskillAnalyticEvent( private val pushNotificationData: PushNotificationData ) : HyperskillAnalyticEvent( - HyperskillAnalyticRoute.Home(), + HyperskillAnalyticRoute.None, HyperskillAnalyticAction.CLICK, HyperskillAnalyticPart.NOTIFICATION ) { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/remote/domain/analytic/PushNotificationShownHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/remote/domain/analytic/PushNotificationShownHyperskillAnalyticEvent.kt index f5b248f3d3..53585a5f15 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/notification/remote/domain/analytic/PushNotificationShownHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/notification/remote/domain/analytic/PushNotificationShownHyperskillAnalyticEvent.kt @@ -14,7 +14,7 @@ import org.hyperskill.app.notification.remote.domain.model.PushNotificationData * JSON payload: * ``` * { - * "route": "/home", + * "route": "None", * "action": "shown", * "part": "notification", * "context": @@ -32,7 +32,7 @@ import org.hyperskill.app.notification.remote.domain.model.PushNotificationData class PushNotificationShownHyperskillAnalyticEvent( private val pushNotificationData: PushNotificationData ) : HyperskillAnalyticEvent( - HyperskillAnalyticRoute.Home(), + HyperskillAnalyticRoute.None, HyperskillAnalyticAction.SHOWN, HyperskillAnalyticPart.NOTIFICATION ) { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/cache/OnboardingCacheDataSourceImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/cache/OnboardingCacheDataSourceImpl.kt index 903fbd6226..c5d995c025 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/cache/OnboardingCacheDataSourceImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/cache/OnboardingCacheDataSourceImpl.kt @@ -27,13 +27,6 @@ internal class OnboardingCacheDataSourceImpl( settings.putBoolean(OnboardingCacheKeyValues.IS_FILL_BLANKS_SELECT_MODE_ONBOARDING_SHOWN, isShown) } - override fun wasNotificationOnboardingShown(): Boolean = - settings.getBoolean(OnboardingCacheKeyValues.IS_NOTIFICATIONS_ONBOARDING_SHOWN, defaultValue = false) - - override fun setNotificationOnboardingWasShown(wasShown: Boolean) { - settings.putBoolean(OnboardingCacheKeyValues.IS_NOTIFICATIONS_ONBOARDING_SHOWN, wasShown) - } - override fun wasFirstProblemOnboardingShown(): Boolean = settings.getBoolean(OnboardingCacheKeyValues.IS_FIRST_PROBLEM_ONBOARDING_SHOWN, defaultValue = false) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/cache/OnboardingCacheKeyValues.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/cache/OnboardingCacheKeyValues.kt index 10c8570bc0..89e49d4ebc 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/cache/OnboardingCacheKeyValues.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/cache/OnboardingCacheKeyValues.kt @@ -5,7 +5,6 @@ internal object OnboardingCacheKeyValues { const val IS_FILL_BLANKS_INPUT_MODE_ONBOARDING_SHOWN = "is_fill_blanks_input_mode_onboarding_shown" const val IS_FILL_BLANKS_SELECT_MODE_ONBOARDING_SHOWN = "is_fill_blanks_select_mode_onboarding_shown" - const val IS_NOTIFICATIONS_ONBOARDING_SHOWN = "is_notifications_onboarding_shown" const val IS_FIRST_PROBLEM_ONBOARDING_SHOWN = "is_first_problem_onboarding_shown" const val IS_INTERVIEW_PREPARATION_ONBOARDING_SHOWN = "is_interview_preparation_onboarding_shown" diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/data/repository/OnboardingRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/data/repository/OnboardingRepositoryImpl.kt index 4d1776d1a4..9f59d6e8cb 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/data/repository/OnboardingRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/data/repository/OnboardingRepositoryImpl.kt @@ -28,13 +28,6 @@ internal class OnboardingRepositoryImpl( onboardingCacheDataSource.setFillBlanksSelectModeOnboardingShown(isShown) } - override fun wasNotificationOnboardingShown(): Boolean = - onboardingCacheDataSource.wasNotificationOnboardingShown() - - override fun setNotificationOnboardingWasShown(wasShown: Boolean) { - onboardingCacheDataSource.setNotificationOnboardingWasShown(wasShown) - } - override fun wasFirstProblemOnboardingShown(): Boolean = onboardingCacheDataSource.wasFirstProblemOnboardingShown() diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/data/source/OnboardingCacheDataSource.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/data/source/OnboardingCacheDataSource.kt index 2372068d66..f4adea9ab4 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/data/source/OnboardingCacheDataSource.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/data/source/OnboardingCacheDataSource.kt @@ -9,9 +9,6 @@ interface OnboardingCacheDataSource { fun isFillBlanksSelectModeOnboardingShown(): Boolean fun setFillBlanksSelectModeOnboardingShown(isShown: Boolean) - fun wasNotificationOnboardingShown(): Boolean - fun setNotificationOnboardingWasShown(wasShown: Boolean) - fun wasFirstProblemOnboardingShown(): Boolean fun setFirstProblemOnboardingWasShown(wasShown: Boolean) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/domain/interactor/OnboardingInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/domain/interactor/OnboardingInteractor.kt index 05c1e43be9..00d4254490 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/domain/interactor/OnboardingInteractor.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/domain/interactor/OnboardingInteractor.kt @@ -22,13 +22,6 @@ class OnboardingInteractor( fun getProblemsOnboardingFlags(): ProblemsOnboardingFlags = onboardingRepository.getProblemsOnboardingFlags() - fun wasNotificationOnboardingShown(): Boolean = - onboardingRepository.wasNotificationOnboardingShown() - - fun setNotificationOnboardingWasShown(wasShown: Boolean) { - onboardingRepository.setNotificationOnboardingWasShown(wasShown) - } - fun wasFirstProblemOnboardingShown(): Boolean = onboardingRepository.wasFirstProblemOnboardingShown() diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/domain/repository/OnboardingRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/domain/repository/OnboardingRepository.kt index ded53ca078..6feaf38fd2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/domain/repository/OnboardingRepository.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/onboarding/domain/repository/OnboardingRepository.kt @@ -11,9 +11,6 @@ interface OnboardingRepository { fun isFillBlanksSelectModeOnboardingShown(): Boolean fun setFillBlanksSelectModeOnboardingShown(isShown: Boolean) - fun wasNotificationOnboardingShown(): Boolean - fun setNotificationOnboardingWasShown(wasShown: Boolean) - fun wasFirstProblemOnboardingShown(): Boolean fun setFirstProblemOnboardingWasShown(wasShown: Boolean) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallAnalyticParams.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallAnalyticParams.kt new file mode 100644 index 0000000000..f702014415 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallAnalyticParams.kt @@ -0,0 +1,5 @@ +package org.hyperskill.app.paywall.domain.analytic + +internal object PaywallAnalyticParams { + const val PARAM_TRANSITION_SOURCE: String = "source" +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedBuySubscriptionHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedBuySubscriptionHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..4fa3bccaa7 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedBuySubscriptionHyperskillAnalyticEvent.kt @@ -0,0 +1,44 @@ +package org.hyperskill.app.paywall.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource + +/** + * Represents a click analytic event of the buy subscription button. + * + * JSON payload: + * ``` + * { + * "route": "/paywall", + * "action": "click", + * "part": "main", + * "target": "buy_subscription", + * "context": + * { + * "source": "login" + * } + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class PaywallClickedBuySubscriptionHyperskillAnalyticEvent( + private val paywallTransitionSource: PaywallTransitionSource +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Paywall, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.BUY_SUBSCRIPTION +) { + override val params: Map + get() = super.params + + mapOf( + PARAM_CONTEXT to mapOf( + PaywallAnalyticParams.PARAM_TRANSITION_SOURCE to paywallTransitionSource.analyticName + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedContinueWithLimitsHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedContinueWithLimitsHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..37485b5ab2 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedContinueWithLimitsHyperskillAnalyticEvent.kt @@ -0,0 +1,44 @@ +package org.hyperskill.app.paywall.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource + +/** + * Represents a click analytic event of the continue-with-limits button. + * + * JSON payload: + * ``` + * { + * "route": "/paywall", + * "action": "click", + * "part": "main", + * "target": "continue_with_limits", + * "context": + * { + * "source": "login" + * } + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class PaywallClickedContinueWithLimitsHyperskillAnalyticEvent( + private val paywallTransitionSource: PaywallTransitionSource +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Paywall, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.CONTINUE_WITH_LIMITS +) { + override val params: Map + get() = super.params + + mapOf( + PARAM_CONTEXT to mapOf( + PaywallAnalyticParams.PARAM_TRANSITION_SOURCE to paywallTransitionSource.analyticName + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedRetryContentLoadingHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedRetryContentLoadingHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..4eb7bd7b4c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedRetryContentLoadingHyperskillAnalyticEvent.kt @@ -0,0 +1,44 @@ +package org.hyperskill.app.paywall.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource + +/** + * Represents a click analytic event of the error state placeholder retry button. + * + * JSON payload: + * ``` + * { + * "route": "/paywall", + * "action": "click", + * "part": "main", + * "target": "retry", + * "context": + * { + * "source": "login" + * } + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class PaywallClickedRetryContentLoadingHyperskillAnalyticEvent( + private val paywallTransitionSource: PaywallTransitionSource +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Paywall, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.RETRY +) { + override val params: Map + get() = super.params + + mapOf( + PARAM_CONTEXT to mapOf( + PaywallAnalyticParams.PARAM_TRANSITION_SOURCE to paywallTransitionSource.analyticName + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedTermsOfServiceAndPrivacyPolicyHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedTermsOfServiceAndPrivacyPolicyHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..0d711f140c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallClickedTermsOfServiceAndPrivacyPolicyHyperskillAnalyticEvent.kt @@ -0,0 +1,44 @@ +package org.hyperskill.app.paywall.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource + +/** + * Represents a click analytic event of the terms of service and privacy policy button. + * + * JSON payload: + * ``` + * { + * "route": "/paywall", + * "action": "click", + * "part": "main", + * "target": "hyperskill_terms_of_service_and_privacy_policy", + * "context": + * { + * "source": "login" + * } + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class PaywallClickedTermsOfServiceAndPrivacyPolicyHyperskillAnalyticEvent( + private val paywallTransitionSource: PaywallTransitionSource +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Paywall, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.HYPERSKILL_TERMS_OF_SERVICE_AND_PRIVACY_POLICY +) { + override val params: Map + get() = super.params + + mapOf( + PARAM_CONTEXT to mapOf( + PaywallAnalyticParams.PARAM_TRANSITION_SOURCE to paywallTransitionSource.analyticName + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallViewedHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallViewedHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..5b8d866a26 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/analytic/PaywallViewedHyperskillAnalyticEvent.kt @@ -0,0 +1,38 @@ +package org.hyperskill.app.paywall.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource + +/** + * Represents a view analytic event. + * + * JSON payload: + * ``` + * { + * "route": "/paywall", + * "action": "view", + * "context": + * { + * "source": "login" + * } + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class PaywallViewedHyperskillAnalyticEvent( + private val paywallTransitionSource: PaywallTransitionSource +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Paywall, + HyperskillAnalyticAction.VIEW +) { + override val params: Map + get() = super.params + + mapOf( + PARAM_CONTEXT to mapOf( + PaywallAnalyticParams.PARAM_TRANSITION_SOURCE to paywallTransitionSource.analyticName + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/model/PaywallTransitionSource.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/model/PaywallTransitionSource.kt new file mode 100644 index 0000000000..70cc6ec2af --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/domain/model/PaywallTransitionSource.kt @@ -0,0 +1,9 @@ +package org.hyperskill.app.paywall.domain.model + +enum class PaywallTransitionSource(val analyticName: String) { + APP_BECOMES_ACTIVE("app_becomes_active"), + LOGIN("login"), + PROFILE_SETTINGS("profile_settings"), + PROBLEMS_LIMIT_MODAL("problems_limit_modal"), + MANAGE_SUBSCRIPTION("manage_subscription") +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallComponent.kt new file mode 100644 index 0000000000..502a167030 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallComponent.kt @@ -0,0 +1,10 @@ +package org.hyperskill.app.paywall.injection + +import org.hyperskill.app.paywall.presentation.PaywallFeature.Action +import org.hyperskill.app.paywall.presentation.PaywallFeature.Message +import org.hyperskill.app.paywall.presentation.PaywallFeature.ViewState +import ru.nobird.app.presentation.redux.feature.Feature + +interface PaywallComponent { + val paywallFeature: Feature +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallComponentImpl.kt new file mode 100644 index 0000000000..f3ee7c9ab8 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallComponentImpl.kt @@ -0,0 +1,26 @@ +package org.hyperskill.app.paywall.injection + +import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.paywall.presentation.PaywallFeature.Action +import org.hyperskill.app.paywall.presentation.PaywallFeature.Message +import org.hyperskill.app.paywall.presentation.PaywallFeature.ViewState +import ru.nobird.app.presentation.redux.feature.Feature + +internal class PaywallComponentImpl( + private val paywallTransitionSource: PaywallTransitionSource, + private val appGraph: AppGraph +) : PaywallComponent { + override val paywallFeature: Feature + get() = PaywallFeatureBuilder.build( + paywallTransitionSource = paywallTransitionSource, + analyticInteractor = appGraph.analyticComponent.analyticInteractor, + purchaseInteractor = appGraph.buildPurchaseComponent().purchaseInteractor, + resourceProvider = appGraph.commonComponent.resourceProvider, + subscriptionsRepository = appGraph.subscriptionDataComponent.subscriptionsRepository, + sentryInteractor = appGraph.sentryComponent.sentryInteractor, + currentSubscriptionStateRepository = appGraph.stateRepositoriesComponent.currentSubscriptionStateRepository, + logger = appGraph.loggerComponent.logger, + buildVariant = appGraph.commonComponent.buildKonfig.buildVariant + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallFeatureBuilder.kt new file mode 100644 index 0000000000..a3af6e8444 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/injection/PaywallFeatureBuilder.kt @@ -0,0 +1,67 @@ +package org.hyperskill.app.paywall.injection + +import co.touchlab.kermit.Logger +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.core.domain.BuildVariant +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.core.presentation.transformState +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.logging.presentation.wrapWithLogger +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.paywall.presentation.PaywallActionDispatcher +import org.hyperskill.app.paywall.presentation.PaywallFeature +import org.hyperskill.app.paywall.presentation.PaywallFeature.Action +import org.hyperskill.app.paywall.presentation.PaywallFeature.Message +import org.hyperskill.app.paywall.presentation.PaywallFeature.ViewState +import org.hyperskill.app.paywall.presentation.PaywallReducer +import org.hyperskill.app.paywall.view.PaywallViewStateMapper +import org.hyperskill.app.purchases.domain.interactor.PurchaseInteractor +import org.hyperskill.app.sentry.domain.interactor.SentryInteractor +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository +import org.hyperskill.app.subscriptions.domain.repository.SubscriptionsRepository +import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher +import ru.nobird.app.presentation.redux.feature.Feature +import ru.nobird.app.presentation.redux.feature.ReduxFeature + +internal object PaywallFeatureBuilder { + private const val LOG_TAG = "PaywallFeature" + + fun build( + paywallTransitionSource: PaywallTransitionSource, + analyticInteractor: AnalyticInteractor, + purchaseInteractor: PurchaseInteractor, + resourceProvider: ResourceProvider, + subscriptionsRepository: SubscriptionsRepository, + currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, + sentryInteractor: SentryInteractor, + logger: Logger, + buildVariant: BuildVariant + ): Feature { + val paywallReducer = PaywallReducer( + paywallTransitionSource = paywallTransitionSource, + resourceProvider = resourceProvider + ) + .wrapWithLogger(buildVariant, logger, LOG_TAG) + + val paywallActionDispatcher = PaywallActionDispatcher( + config = ActionDispatcherOptions(), + analyticInteractor = analyticInteractor, + purchaseInteractor = purchaseInteractor, + subscriptionsRepository = subscriptionsRepository, + sentryInteractor = sentryInteractor, + currentSubscriptionStateRepository = currentSubscriptionStateRepository, + logger = logger.withTag(LOG_TAG) + ) + + val viewStateMapper = PaywallViewStateMapper(resourceProvider) + + return ReduxFeature( + initialState = PaywallFeature.State.Idle, + reducer = paywallReducer + ) + .wrapWithActionDispatcher(paywallActionDispatcher) + .transformState { state -> + viewStateMapper.map(state, paywallTransitionSource) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallActionDispatcher.kt new file mode 100644 index 0000000000..689449c905 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallActionDispatcher.kt @@ -0,0 +1,109 @@ +package org.hyperskill.app.paywall.presentation + +import co.touchlab.kermit.Logger +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.paywall.presentation.PaywallFeature.Action +import org.hyperskill.app.paywall.presentation.PaywallFeature.InternalAction +import org.hyperskill.app.paywall.presentation.PaywallFeature.InternalMessage +import org.hyperskill.app.paywall.presentation.PaywallFeature.Message +import org.hyperskill.app.purchases.domain.interactor.PurchaseInteractor +import org.hyperskill.app.purchases.domain.model.PurchaseResult +import org.hyperskill.app.sentry.domain.interactor.SentryInteractor +import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder +import org.hyperskill.app.sentry.domain.withTransaction +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository +import org.hyperskill.app.subscriptions.domain.repository.SubscriptionsRepository +import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher + +internal class PaywallActionDispatcher( + config: ActionDispatcherOptions, + private val analyticInteractor: AnalyticInteractor, + private val purchaseInteractor: PurchaseInteractor, + private val subscriptionsRepository: SubscriptionsRepository, + private val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, + private val sentryInteractor: SentryInteractor, + private val logger: Logger +) : CoroutineActionDispatcher(config.createConfig()) { + override suspend fun doSuspendableAction(action: Action) { + when (action) { + is InternalAction.FetchMobileOnlyPrice -> + handleFetchMobileOnlyPrice(::onNewMessage) + is InternalAction.StartMobileOnlySubscriptionPurchase -> + handleStartMobileOnlySubscriptionPurchase(action, ::onNewMessage) + is InternalAction.SyncSubscription -> + handleSyncSubscription(::onNewMessage) + is InternalAction.LogWrongSubscriptionTypeAfterSync -> + handleLogWrongSubscriptionTypeAfterSync(action) + is InternalAction.LogAnalyticEvent -> + analyticInteractor.logEvent(action.analyticEvent) + else -> { + // no op + } + } + } + + private suspend fun handleFetchMobileOnlyPrice(onNewMessage: (Message) -> Unit) { + sentryInteractor.withTransaction( + transaction = HyperskillSentryTransactionBuilder.buildPaywallFetchSubscriptionPrice(), + onError = { InternalMessage.FetchMobileOnlyPriceError } + ) { + val price = purchaseInteractor + .getFormattedMobileOnlySubscriptionPrice() + .getOrThrow() + + if (price != null) { + InternalMessage.FetchMobileOnlyPriceSuccess(price) + } else { + logger.e { "Receive null instead of formatted mobile-only subscription price" } + InternalMessage.FetchMobileOnlyPriceError + } + }.let(onNewMessage) + } + + private suspend fun handleStartMobileOnlySubscriptionPurchase( + action: InternalAction.StartMobileOnlySubscriptionPurchase, + onNewMessage: (Message) -> Unit + ) { + sentryInteractor.withTransaction( + transaction = HyperskillSentryTransactionBuilder.buildPaywallFeaturePurchaseSubscription(), + onError = { InternalMessage.MobileOnlySubscriptionPurchaseError } + ) { + val purchaseResult = purchaseInteractor + .purchaseMobileOnlySubscription(action.purchaseParams) + .getOrThrow() + + if (purchaseResult is PurchaseResult.Error) { + logger.e { getPurchaseErrorMessage(purchaseResult) } + } + + InternalMessage.MobileOnlySubscriptionPurchaseSuccess(purchaseResult) + }.let(onNewMessage) + } + + private fun getPurchaseErrorMessage(error: PurchaseResult.Error): String = + "Subscription purchase failed!\n${error.message}\n${error.underlyingErrorMessage}" + + private suspend fun handleSyncSubscription(onNewMessage: (Message) -> Unit) { + sentryInteractor.withTransaction( + transaction = HyperskillSentryTransactionBuilder.buildPaywallFeatureSyncSubscription(), + onError = { InternalMessage.SubscriptionSyncError } + ) { + val subscription = subscriptionsRepository.syncSubscription().getOrThrow() + currentSubscriptionStateRepository.updateState(subscription) + InternalMessage.SubscriptionSyncSuccess(subscription) + }.let(onNewMessage) + } + + private fun handleLogWrongSubscriptionTypeAfterSync( + action: InternalAction.LogWrongSubscriptionTypeAfterSync + ) { + logger.e { + """ + Wrong subscription type after sync: + expected=${action.expectedSubscriptionType} + actual=${action.actualSubscriptionType} + """.trimIndent() + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallFeature.kt new file mode 100644 index 0000000000..828f520692 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallFeature.kt @@ -0,0 +1,112 @@ +package org.hyperskill.app.paywall.presentation + +import dev.icerock.moko.resources.StringResource +import org.hyperskill.app.SharedResources +import org.hyperskill.app.analytic.domain.model.AnalyticEvent +import org.hyperskill.app.purchases.domain.model.PlatformPurchaseParams +import org.hyperskill.app.purchases.domain.model.PurchaseResult +import org.hyperskill.app.subscriptions.domain.model.Subscription +import org.hyperskill.app.subscriptions.domain.model.SubscriptionType + +object PaywallFeature { + internal sealed interface State { + object Idle : State + object Loading : State + object Error : State + data class Content( + val formattedPrice: String, + val isPurchaseSyncLoadingShowed: Boolean = false + ) : State + } + + data class ViewState( + val isToolbarVisible: Boolean, + val contentState: ViewStateContent + ) + + sealed interface ViewStateContent { + object Idle : ViewStateContent + object Loading : ViewStateContent + object Error : ViewStateContent + data class Content( + val buyButtonText: String, + val isContinueWithLimitsButtonVisible: Boolean + ) : ViewStateContent + + object SubscriptionSyncLoading : ViewStateContent + } + + sealed interface Message { + object Initialize : Message + + object RetryContentLoading : Message + + object ContinueWithLimitsClicked : Message + + data class BuySubscriptionClicked( + val purchaseParams: PlatformPurchaseParams + ) : Message + + object ClickedTermsOfServiceAndPrivacyPolicy : Message + + object ViewedEventMessage : Message + } + + internal sealed interface InternalMessage : Message { + object FetchMobileOnlyPriceError : InternalMessage + data class FetchMobileOnlyPriceSuccess(val formattedPrice: String) : InternalMessage + + object MobileOnlySubscriptionPurchaseError : InternalMessage + data class MobileOnlySubscriptionPurchaseSuccess( + val purchaseResult: PurchaseResult + ) : InternalMessage + + object SubscriptionSyncError : InternalMessage + data class SubscriptionSyncSuccess(val subscription: Subscription) : InternalMessage + } + + sealed interface Action { + sealed interface ViewAction : Action { + object CompletePaywall : ViewAction + + object ClosePaywall : ViewAction + + object StudyPlan : ViewAction + + data class ShowMessage( + val messageKind: MessageKind + ) : ViewAction + + data class OpenUrl(val url: String) : ViewAction + + sealed interface NavigateTo : ViewAction { + object BackToProfileSettings : NavigateTo + } + } + } + + enum class MessageKind( + val stringRes: StringResource + ) { + GENERAL(SharedResources.strings.paywall_purchase_error_message), + PENDING_PURCHASE(SharedResources.strings.paywall_pending_purchase), + SUBSCRIPTION_WILL_BECOME_AVAILABLE_SOON(SharedResources.strings.paywall_subscription_sync_delayed) + } + + internal sealed interface InternalAction : Action { + object FetchMobileOnlyPrice : InternalAction + + data class StartMobileOnlySubscriptionPurchase( + val purchaseParams: PlatformPurchaseParams + ) : InternalAction + + object SyncSubscription : InternalAction + + data class LogWrongSubscriptionTypeAfterSync( + val expectedSubscriptionType: SubscriptionType, + val actualSubscriptionType: SubscriptionType + ) : InternalAction + + data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallReducer.kt new file mode 100644 index 0000000000..998c00bc25 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/presentation/PaywallReducer.kt @@ -0,0 +1,201 @@ +package org.hyperskill.app.paywall.presentation + +import org.hyperskill.app.SharedResources +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.paywall.domain.analytic.PaywallClickedBuySubscriptionHyperskillAnalyticEvent +import org.hyperskill.app.paywall.domain.analytic.PaywallClickedContinueWithLimitsHyperskillAnalyticEvent +import org.hyperskill.app.paywall.domain.analytic.PaywallClickedRetryContentLoadingHyperskillAnalyticEvent +import org.hyperskill.app.paywall.domain.analytic.PaywallClickedTermsOfServiceAndPrivacyPolicyHyperskillAnalyticEvent +import org.hyperskill.app.paywall.domain.analytic.PaywallViewedHyperskillAnalyticEvent +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.paywall.presentation.PaywallFeature.Action +import org.hyperskill.app.paywall.presentation.PaywallFeature.InternalAction +import org.hyperskill.app.paywall.presentation.PaywallFeature.InternalMessage +import org.hyperskill.app.paywall.presentation.PaywallFeature.Message +import org.hyperskill.app.paywall.presentation.PaywallFeature.State +import org.hyperskill.app.purchases.domain.model.PurchaseResult +import org.hyperskill.app.subscriptions.domain.model.SubscriptionType +import ru.nobird.app.presentation.redux.reducer.StateReducer + +private typealias ReducerResult = Pair> + +internal class PaywallReducer( + private val paywallTransitionSource: PaywallTransitionSource, + private val resourceProvider: ResourceProvider +) : StateReducer { + override fun reduce(state: State, message: Message): ReducerResult = + when (message) { + Message.Initialize -> fetchMobileOnlyPrice() + Message.RetryContentLoading -> + fetchMobileOnlyPrice( + setOf( + InternalAction.LogAnalyticEvent( + PaywallClickedRetryContentLoadingHyperskillAnalyticEvent(paywallTransitionSource) + ) + ) + ) + is InternalMessage.FetchMobileOnlyPriceSuccess -> + handleFetchMobileOnlyPriceSuccess(message) + InternalMessage.FetchMobileOnlyPriceError -> + handleFetchMobileOnlyPriceError() + Message.ContinueWithLimitsClicked -> + handleContinueWithLimitsClicked(state) + is Message.BuySubscriptionClicked -> + handleBuySubscriptionClicked(state, message) + is InternalMessage.MobileOnlySubscriptionPurchaseSuccess -> + handleMobileOnlySubscriptionPurchaseSuccess(state, message) + InternalMessage.MobileOnlySubscriptionPurchaseError -> + handleMobileOnlySubscriptionPurchaseError(state) + is InternalMessage.SubscriptionSyncSuccess -> + handleSubscriptionSyncSuccess(state, message) + InternalMessage.SubscriptionSyncError -> + handleSubscriptionSyncError(state) + Message.ClickedTermsOfServiceAndPrivacyPolicy -> + handleClickedTermsOfServiceAndPrivacyPolicy(state) + Message.ViewedEventMessage -> + handleViewedEventMessage(state) + } + + private fun fetchMobileOnlyPrice(actions: Set = emptySet()): ReducerResult = + State.Loading to setOf(InternalAction.FetchMobileOnlyPrice) + actions + + private fun handleFetchMobileOnlyPriceSuccess( + message: InternalMessage.FetchMobileOnlyPriceSuccess + ): ReducerResult = + State.Content(message.formattedPrice) to emptySet() + + private fun handleFetchMobileOnlyPriceError(): ReducerResult = + State.Error to setOf() + + private fun handleContinueWithLimitsClicked( + state: State + ): ReducerResult = + state to setOf( + InternalAction.LogAnalyticEvent( + PaywallClickedContinueWithLimitsHyperskillAnalyticEvent( + paywallTransitionSource + ) + ), + getTargetScreenNavigationAction(paywallTransitionSource) + ) + + private fun handleBuySubscriptionClicked( + state: State, + message: Message.BuySubscriptionClicked + ): ReducerResult = + state to setOf( + InternalAction.LogAnalyticEvent( + PaywallClickedBuySubscriptionHyperskillAnalyticEvent( + paywallTransitionSource + ) + ), + InternalAction.StartMobileOnlySubscriptionPurchase(message.purchaseParams) + ) + + private fun handleMobileOnlySubscriptionPurchaseSuccess( + state: State, + message: InternalMessage.MobileOnlySubscriptionPurchaseSuccess + ): ReducerResult = + if (state is State.Content) { + when (message.purchaseResult) { + is PurchaseResult.Succeed, + is PurchaseResult.Error.ProductAlreadyPurchasedError -> { + state.copy(isPurchaseSyncLoadingShowed = true) to + setOf(InternalAction.SyncSubscription) + } + PurchaseResult.CancelledByUser -> state to emptySet() + is PurchaseResult.Error.PaymentPendingError -> { + state to setOf( + Action.ViewAction.ShowMessage( + PaywallFeature.MessageKind.PENDING_PURCHASE + ), + getTargetScreenNavigationAction(paywallTransitionSource) + ) + } + + is PurchaseResult.Error.ErrorWhileFetchingProduct, + is PurchaseResult.Error.NoProductFound, + is PurchaseResult.Error.PurchaseNotAllowedError, + is PurchaseResult.Error.ReceiptAlreadyInUseError, + is PurchaseResult.Error.StoreProblemError, + is PurchaseResult.Error.OtherError -> handleMobileOnlySubscriptionPurchaseError(state) + } + } else { + state to emptySet() + } + + private fun handleMobileOnlySubscriptionPurchaseError(state: State): ReducerResult = + state to setOf(Action.ViewAction.ShowMessage(PaywallFeature.MessageKind.GENERAL)) + + private fun handleSubscriptionSyncSuccess( + state: State, + message: InternalMessage.SubscriptionSyncSuccess + ): ReducerResult = + if (state is State.Content) { + state.copy(isPurchaseSyncLoadingShowed = false) to + if (message.subscription.type == SubscriptionType.MOBILE_ONLY) { + setOf(getTargetScreenNavigationAction(paywallTransitionSource)) + } else { + setOf( + Action.ViewAction.ShowMessage( + PaywallFeature.MessageKind.SUBSCRIPTION_WILL_BECOME_AVAILABLE_SOON + ), + InternalAction.LogWrongSubscriptionTypeAfterSync( + expectedSubscriptionType = SubscriptionType.MOBILE_ONLY, + actualSubscriptionType = message.subscription.type + ), + getTargetScreenNavigationAction(paywallTransitionSource) + ) + } + } else { + state to emptySet() + } + + private fun handleSubscriptionSyncError(state: State): ReducerResult = + if (state is State.Content) { + state.copy(isPurchaseSyncLoadingShowed = false) to setOf( + Action.ViewAction.ShowMessage( + PaywallFeature.MessageKind.SUBSCRIPTION_WILL_BECOME_AVAILABLE_SOON + ), + getTargetScreenNavigationAction(paywallTransitionSource) + ) + } else { + state to emptySet() + } + + private fun getTargetScreenNavigationAction( + paywallTransitionSource: PaywallTransitionSource + ): Action.ViewAction = + when (paywallTransitionSource) { + PaywallTransitionSource.APP_BECOMES_ACTIVE, + PaywallTransitionSource.MANAGE_SUBSCRIPTION -> + Action.ViewAction.ClosePaywall + PaywallTransitionSource.LOGIN -> + Action.ViewAction.CompletePaywall + PaywallTransitionSource.PROFILE_SETTINGS -> + Action.ViewAction.NavigateTo.BackToProfileSettings + PaywallTransitionSource.PROBLEMS_LIMIT_MODAL -> + Action.ViewAction.StudyPlan + } + + private fun handleClickedTermsOfServiceAndPrivacyPolicy(state: State): ReducerResult = + if (state is State.Content) { + state to setOf( + InternalAction.LogAnalyticEvent( + PaywallClickedTermsOfServiceAndPrivacyPolicyHyperskillAnalyticEvent(paywallTransitionSource) + ), + Action.ViewAction.OpenUrl( + resourceProvider.getString(SharedResources.strings.paywall_tos_and_privacy_url) + ) + ) + } else { + state to emptySet() + } + + private fun handleViewedEventMessage(state: State): ReducerResult = + state to setOf( + InternalAction.LogAnalyticEvent( + PaywallViewedHyperskillAnalyticEvent(paywallTransitionSource) + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/view/PaywallViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/view/PaywallViewStateMapper.kt new file mode 100644 index 0000000000..d603b3d9fb --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/paywall/view/PaywallViewStateMapper.kt @@ -0,0 +1,52 @@ +package org.hyperskill.app.paywall.view + +import org.hyperskill.app.SharedResources +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource.APP_BECOMES_ACTIVE +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource.LOGIN +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource.MANAGE_SUBSCRIPTION +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource.PROBLEMS_LIMIT_MODAL +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource.PROFILE_SETTINGS +import org.hyperskill.app.paywall.presentation.PaywallFeature.State +import org.hyperskill.app.paywall.presentation.PaywallFeature.ViewState +import org.hyperskill.app.paywall.presentation.PaywallFeature.ViewStateContent + +internal class PaywallViewStateMapper( + private val resourceProvider: ResourceProvider +) { + fun map( + state: State, + paywallTransitionSource: PaywallTransitionSource + ): ViewState = + ViewState( + isToolbarVisible = when (paywallTransitionSource) { + APP_BECOMES_ACTIVE, LOGIN -> false + MANAGE_SUBSCRIPTION, + PROFILE_SETTINGS, + PROBLEMS_LIMIT_MODAL -> true + }, + contentState = when (state) { + State.Idle -> ViewStateContent.Idle + State.Loading -> ViewStateContent.Loading + State.Error -> ViewStateContent.Error + is State.Content -> + if (state.isPurchaseSyncLoadingShowed) { + ViewStateContent.SubscriptionSyncLoading + } else { + ViewStateContent.Content( + buyButtonText = resourceProvider.getString( + SharedResources.strings.paywall_mobile_only_buy_btn, + state.formattedPrice + ), + isContinueWithLimitsButtonVisible = when (paywallTransitionSource) { + PROFILE_SETTINGS, MANAGE_SUBSCRIPTION -> false + APP_BECOMES_ACTIVE, + LOGIN, + PROBLEMS_LIMIT_MODAL -> true + } + ) + } + } + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/domain/analytic/ProblemsLimitClickedRetryContentLoadingHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/domain/analytic/ProblemsLimitClickedRetryContentLoadingHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..b85410907e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/domain/analytic/ProblemsLimitClickedRetryContentLoadingHyperskillAnalyticEvent.kt @@ -0,0 +1,30 @@ +package org.hyperskill.app.problems_limit.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget +import org.hyperskill.app.problems_limit.domain.model.ProblemsLimitScreen + +/** + * Represents a click analytic event of the error state placeholder retry button. + * + * JSON payload: + * ``` + * { + * "route": "/home", + * "action": "click", + * "part": "problems_limit_widget", + * "target": "retry" + * } + * ``` + * @see HyperskillAnalyticEvent + */ +class ProblemsLimitClickedRetryContentLoadingHyperskillAnalyticEvent( + screen: ProblemsLimitScreen +) : HyperskillAnalyticEvent( + screen.analyticRoute, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.PROBLEMS_LIMIT_WIDGET, + HyperskillAnalyticTarget.RETRY +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/domain/model/ProblemsLimitScreen.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/domain/model/ProblemsLimitScreen.kt index 96801e50d0..bf13c0e5ba 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/domain/model/ProblemsLimitScreen.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/domain/model/ProblemsLimitScreen.kt @@ -1,5 +1,6 @@ package org.hyperskill.app.problems_limit.domain.model +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransaction import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder @@ -12,4 +13,10 @@ enum class ProblemsLimitScreen { HOME -> HyperskillSentryTransactionBuilder.buildProblemsLimitHomeScreenRemoteDataLoading() STUDY_PLAN -> HyperskillSentryTransactionBuilder.buildProblemsLimitStudyPlanScreenRemoteDataLoading() } + + internal val analyticRoute: HyperskillAnalyticRoute + get() = when (this) { + HOME -> HyperskillAnalyticRoute.Home() + STUDY_PLAN -> HyperskillAnalyticRoute.StudyPlan() + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/injection/ProblemsLimitComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/injection/ProblemsLimitComponentImpl.kt index d6f4ccc3f8..fe95b66abd 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/injection/ProblemsLimitComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/injection/ProblemsLimitComponentImpl.kt @@ -7,7 +7,7 @@ import org.hyperskill.app.problems_limit.presentation.ProblemsLimitActionDispatc import org.hyperskill.app.problems_limit.presentation.ProblemsLimitReducer import org.hyperskill.app.problems_limit.view.mapper.ProblemsLimitViewStateMapper -class ProblemsLimitComponentImpl( +internal class ProblemsLimitComponentImpl( private val screen: ProblemsLimitScreen, private val appGraph: AppGraph ) : ProblemsLimitComponent { @@ -16,11 +16,13 @@ class ProblemsLimitComponentImpl( override val problemsLimitActionDispatcher: ProblemsLimitActionDispatcher get() = ProblemsLimitActionDispatcher( - ActionDispatcherOptions(), - appGraph.buildFreemiumDataComponent().freemiumInteractor, - appGraph.sentryComponent.sentryInteractor, - appGraph.stateRepositoriesComponent.currentSubscriptionStateRepository + config = ActionDispatcherOptions(), + sentryInteractor = appGraph.sentryComponent.sentryInteractor, + analyticInteractor = appGraph.analyticComponent.analyticInteractor, + currentSubscriptionStateRepository = appGraph.stateRepositoriesComponent.currentSubscriptionStateRepository, + currentProfileStateRepository = appGraph.profileDataComponent.currentProfileStateRepository ) + override val problemsLimitViewStateMapper: ProblemsLimitViewStateMapper get() = ProblemsLimitViewStateMapper( appGraph.commonComponent.resourceProvider, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/presentation/ProblemsLimitActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/presentation/ProblemsLimitActionDispatcher.kt index 02ef4fc3aa..8d25999dcf 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/presentation/ProblemsLimitActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/presentation/ProblemsLimitActionDispatcher.kt @@ -4,25 +4,31 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.core.presentation.Timer -import org.hyperskill.app.freemium.domain.interactor.FreemiumInteractor import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature.Action +import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature.InternalAction +import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature.InternalMessage import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature.Message +import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository +import org.hyperskill.app.profile.domain.repository.isFreemiumWrongSubmissionChargeLimitsEnabled import org.hyperskill.app.sentry.domain.interactor.SentryInteractor +import org.hyperskill.app.sentry.domain.withTransaction import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher class ProblemsLimitActionDispatcher( config: ActionDispatcherOptions, - private val freemiumInteractor: FreemiumInteractor, private val sentryInteractor: SentryInteractor, - private val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository + private val analyticInteractor: AnalyticInteractor, + private val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, + private val currentProfileStateRepository: CurrentProfileStateRepository ) : CoroutineActionDispatcher(config.createConfig()) { init { currentSubscriptionStateRepository.changes .onEach { - onNewMessage(Message.SubscriptionChanged(it)) + onNewMessage(InternalMessage.SubscriptionChanged(it)) } .launchIn(actionScope) } @@ -32,44 +38,18 @@ class ProblemsLimitActionDispatcher( override suspend fun doSuspendableAction(action: Action) { when (action) { - is Action.LoadSubscription -> { - val sentryTransaction = action.screen.sentryTransaction - sentryInteractor.startTransaction(sentryTransaction) - - val isFreemiumEnabled = freemiumInteractor - .isFreemiumEnabled() - .getOrElse { - sentryInteractor.finishTransaction(sentryTransaction, throwable = it) - return onNewMessage(Message.SubscriptionLoadingResult.Error) - } - - onNewMessage( - currentSubscriptionStateRepository.getState(forceUpdate = action.forceUpdate) - .fold( - onSuccess = { - sentryInteractor.finishTransaction(sentryTransaction) - Message.SubscriptionLoadingResult.Success( - subscription = it, - isFreemiumEnabled = isFreemiumEnabled - ) - }, - onFailure = { - sentryInteractor.finishTransaction(sentryTransaction, throwable = it) - Message.SubscriptionLoadingResult.Error - } - ) - ) - } - is Action.LaunchTimer -> { + is InternalAction.LoadSubscription -> + handleLoadSubscriptionAction(action, ::onNewMessage) + is InternalAction.LaunchTimer -> { timerMutex.withLock { timer?.stop() timer = Timer( duration = action.updateIn, - onChange = { onNewMessage(Message.UpdateInChanged(it)) }, + onChange = { onNewMessage(InternalMessage.UpdateInChanged(it)) }, onFinish = { currentSubscriptionStateRepository.resetState() - onNewMessage(Message.Initialize(forceUpdate = true)) + onNewMessage(InternalMessage.Initialize(forceUpdate = true)) }, launchIn = actionScope ) @@ -77,6 +57,30 @@ class ProblemsLimitActionDispatcher( timer?.start() } } + is InternalAction.LogAnalyticEvent -> + analyticInteractor.logEvent(action.analyticEvent) } } + + private suspend fun handleLoadSubscriptionAction( + action: InternalAction.LoadSubscription, + onNewMessage: (Message) -> Unit + ) { + sentryInteractor.withTransaction( + action.screen.sentryTransaction, + onError = { InternalMessage.LoadSubscriptionResultError } + ) { + val currentSubscription = currentSubscriptionStateRepository + .getState(forceUpdate = action.forceUpdate) + .getOrThrow() + + val isFreemiumWrongSubmissionChargeLimitsEnabled = + currentProfileStateRepository.isFreemiumWrongSubmissionChargeLimitsEnabled() + + InternalMessage.LoadSubscriptionResultSuccess( + subscription = currentSubscription, + isFreemiumWrongSubmissionChargeLimitsEnabled = isFreemiumWrongSubmissionChargeLimitsEnabled + ) + }.let(onNewMessage) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/presentation/ProblemsLimitFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/presentation/ProblemsLimitFeature.kt index babbc9c4e2..daf9dfe073 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/presentation/ProblemsLimitFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/presentation/ProblemsLimitFeature.kt @@ -1,6 +1,7 @@ package org.hyperskill.app.problems_limit.presentation import kotlin.time.Duration +import org.hyperskill.app.analytic.domain.model.AnalyticEvent import org.hyperskill.app.problems_limit.domain.model.ProblemsLimitScreen import org.hyperskill.app.subscriptions.domain.model.Subscription @@ -12,7 +13,7 @@ object ProblemsLimitFeature { data class Content( val subscription: Subscription, - val isFreemiumEnabled: Boolean, + val isFreemiumWrongSubmissionChargeLimitsEnabled: Boolean, val updateIn: Duration?, internal val isRefreshing: Boolean = false ) : State @@ -47,29 +48,35 @@ object ProblemsLimitFeature { } sealed interface Message { - data class Initialize(val forceUpdate: Boolean = false) : Message - - object PullToRefresh : Message + object RetryContentLoading : Message + } - sealed interface SubscriptionLoadingResult : Message { - data class Success( - val subscription: Subscription, - val isFreemiumEnabled: Boolean - ) : SubscriptionLoadingResult + internal sealed interface InternalMessage : Message { + data class Initialize(val forceUpdate: Boolean = false) : InternalMessage + object PullToRefresh : InternalMessage - object Error : SubscriptionLoadingResult - } - - data class UpdateInChanged(val newUpdateIn: Duration) : Message + object LoadSubscriptionResultError : InternalMessage + data class LoadSubscriptionResultSuccess( + val subscription: Subscription, + val isFreemiumWrongSubmissionChargeLimitsEnabled: Boolean + ) : InternalMessage - data class SubscriptionChanged(val newSubscription: Subscription) : Message + data class UpdateInChanged(val newUpdateIn: Duration) : InternalMessage + data class SubscriptionChanged(val newSubscription: Subscription) : InternalMessage } sealed interface Action { - data class LoadSubscription(val screen: ProblemsLimitScreen, val forceUpdate: Boolean) : Action + sealed interface ViewAction : Action + } - data class LaunchTimer(val updateIn: Duration) : Action + internal sealed interface InternalAction : Action { + data class LoadSubscription( + val screen: ProblemsLimitScreen, + val forceUpdate: Boolean + ) : InternalAction - sealed interface ViewAction : Action + data class LaunchTimer(val updateIn: Duration) : InternalAction + + data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/presentation/ProblemsLimitReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/presentation/ProblemsLimitReducer.kt index 909a17b5e4..8f09fd77c0 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/presentation/ProblemsLimitReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/presentation/ProblemsLimitReducer.kt @@ -2,8 +2,11 @@ package org.hyperskill.app.problems_limit.presentation import kotlin.time.Duration import kotlinx.datetime.Clock +import org.hyperskill.app.problems_limit.domain.analytic.ProblemsLimitClickedRetryContentLoadingHyperskillAnalyticEvent import org.hyperskill.app.problems_limit.domain.model.ProblemsLimitScreen import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature.Action +import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature.InternalAction +import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature.InternalMessage import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature.Message import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature.State import org.hyperskill.app.subscriptions.domain.model.Subscription @@ -12,54 +15,69 @@ import ru.nobird.app.presentation.redux.reducer.StateReducer class ProblemsLimitReducer(private val screen: ProblemsLimitScreen) : StateReducer { override fun reduce(state: State, message: Message): Pair> = when (message) { - is Message.Initialize -> + is InternalMessage.Initialize -> if (state is State.Idle || message.forceUpdate) { State.Loading to setOf( - Action.LoadSubscription(screen = screen, forceUpdate = message.forceUpdate) + InternalAction.LoadSubscription(screen = screen, forceUpdate = message.forceUpdate) ) } else { null } - Message.SubscriptionLoadingResult.Error -> + Message.RetryContentLoading -> + if (state is State.NetworkError) { + State.Loading to setOf( + InternalAction.LoadSubscription(screen = screen, forceUpdate = true), + InternalAction.LogAnalyticEvent( + ProblemsLimitClickedRetryContentLoadingHyperskillAnalyticEvent(screen) + ) + ) + } else { + null + } + InternalMessage.LoadSubscriptionResultError -> State.NetworkError to emptySet() - is Message.SubscriptionLoadingResult.Success -> { + is InternalMessage.LoadSubscriptionResultSuccess -> { val updateIn = calculateUpdateInDuration(message.subscription) - State.Content(message.subscription, message.isFreemiumEnabled, updateIn) to buildSet { + State.Content( + subscription = message.subscription, + isFreemiumWrongSubmissionChargeLimitsEnabled = message.isFreemiumWrongSubmissionChargeLimitsEnabled, + updateIn = updateIn + ) to buildSet { if (updateIn != null) { - add(Action.LaunchTimer(updateIn)) + add(InternalAction.LaunchTimer(updateIn)) } } } - is Message.UpdateInChanged -> + is InternalMessage.UpdateInChanged -> if (state is State.Content) { state.copy(updateIn = message.newUpdateIn) to emptySet() } else { null } - is Message.SubscriptionChanged -> + is InternalMessage.SubscriptionChanged -> if (state is State.Content) { val updateIn = calculateUpdateInDuration(message.newSubscription) state.copy(subscription = message.newSubscription) to buildSet { if (updateIn != null) { - add(Action.LaunchTimer(updateIn)) + add(InternalAction.LaunchTimer(updateIn)) } } } else { null } - is Message.PullToRefresh -> + is InternalMessage.PullToRefresh -> when (state) { is State.Content -> if (state.isRefreshing) { null } else state.copy(isRefreshing = true) to setOf( - Action.LoadSubscription(screen = screen, forceUpdate = true) + InternalAction.LoadSubscription(screen = screen, forceUpdate = true) ) is State.NetworkError -> State.Loading to setOf( - Action.LoadSubscription(screen = screen, forceUpdate = false) + InternalAction.LoadSubscription(screen = screen, forceUpdate = true) ) else -> null diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/view/mapper/ProblemsLimitViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/view/mapper/ProblemsLimitViewStateMapper.kt index 771dcc1312..17abc70477 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/view/mapper/ProblemsLimitViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/problems_limit/view/mapper/ProblemsLimitViewStateMapper.kt @@ -4,6 +4,7 @@ import org.hyperskill.app.SharedResources import org.hyperskill.app.core.view.mapper.ResourceProvider import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature +import org.hyperskill.app.subscriptions.domain.model.areProblemsLimited class ProblemsLimitViewStateMapper( private val resourceProvider: ResourceProvider, @@ -18,15 +19,15 @@ class ProblemsLimitViewStateMapper( val stepsLimitLeft = state.subscription.stepsLimitLeft val stepsLimitTotal = state.subscription.stepsLimitTotal when { - !state.isFreemiumEnabled || + !state.subscription.areProblemsLimited || stepsLimitLeft == null || stepsLimitTotal == null -> ProblemsLimitFeature.ViewState.Content.Empty else -> ProblemsLimitFeature.ViewState.Content.Widget( progress = (stepsLimitLeft.toFloat() / stepsLimitTotal), - stepsLimitLabel = resourceProvider.getString( - SharedResources.strings.problems_limit_widget_problems_limit, - state.subscription.stepsLimitLeft, - state.subscription.stepsLimitTotal + stepsLimitLabel = getStepsLimitLabel( + state.isFreemiumWrongSubmissionChargeLimitsEnabled, + stepsLimitLeft, + stepsLimitTotal ), updateInLabel = state.updateIn?.let { updateIn -> resourceProvider.getString( @@ -38,4 +39,23 @@ class ProblemsLimitViewStateMapper( } } } + + private fun getStepsLimitLabel( + isFreemiumWrongSubmissionChargeLimitsEnabled: Boolean, + stepsLimitLeft: Int, + stepsLimitTotal: Int + ): String = + if (isFreemiumWrongSubmissionChargeLimitsEnabled) { + resourceProvider.getString( + SharedResources.strings.problems_limit_widget_lives_left, + stepsLimitLeft, + stepsLimitTotal + ) + } else { + resourceProvider.getString( + SharedResources.strings.problems_limit_widget_problems_limit, + stepsLimitLeft, + stepsLimitTotal + ) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/EarnedBadgeModalHiddenHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/EarnedBadgeModalHiddenHyperskillAnalyticEvent.kt similarity index 94% rename from shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/EarnedBadgeModalHiddenHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/EarnedBadgeModalHiddenHyperskillAnalyticEvent.kt index 77ea6244f2..de37e08eaa 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/EarnedBadgeModalHiddenHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/EarnedBadgeModalHiddenHyperskillAnalyticEvent.kt @@ -8,7 +8,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar import org.hyperskill.app.badges.domain.model.BadgeKind /** - * Represents a hidden analytic event of the earned badge modal analytics event. + * Represents a hidden analytic event of the earned badge modal analytic event. * * JSON payload: * ``` @@ -24,7 +24,7 @@ import org.hyperskill.app.badges.domain.model.BadgeKind * ``` * @see HyperskillAnalyticEvent */ -class EarnedBadgeModalHiddenHyperskillAnalyticsEvent( +class EarnedBadgeModalHiddenHyperskillAnalyticEvent( private val badgeKind: BadgeKind ) : HyperskillAnalyticEvent( HyperskillAnalyticRoute.Profile(), diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/EarnedBadgeModalShownHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/EarnedBadgeModalShownHyperskillAnalyticEvent.kt index 98f39cc839..ae7503c8f9 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/EarnedBadgeModalShownHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/EarnedBadgeModalShownHyperskillAnalyticEvent.kt @@ -8,7 +8,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar import org.hyperskill.app.badges.domain.model.BadgeKind /** - * Represents show of the earned badge modal analytics event. + * Represents show of the earned badge modal analytic event. * * JSON payload: * ``` @@ -22,6 +22,7 @@ import org.hyperskill.app.badges.domain.model.BadgeKind * } * } * ``` + * * @see HyperskillAnalyticEvent */ class EarnedBadgeModalShownHyperskillAnalyticEvent( diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileBadgeModalHiddenHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileBadgeModalHiddenHyperskillAnalyticEvent.kt similarity index 93% rename from shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileBadgeModalHiddenHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileBadgeModalHiddenHyperskillAnalyticEvent.kt index 2414552214..9e0bf7e7ca 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileBadgeModalHiddenHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileBadgeModalHiddenHyperskillAnalyticEvent.kt @@ -8,7 +8,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar import org.hyperskill.app.badges.domain.model.BadgeKind /** - * Represents a hidden analytic event of the badge detailed modal in profile analytics event. + * Represents a hidden analytic event of the badge detailed modal in profile analytic event. * * JSON payload: * ``` @@ -24,7 +24,7 @@ import org.hyperskill.app.badges.domain.model.BadgeKind * ``` * @see HyperskillAnalyticEvent */ -class ProfileBadgeModalHiddenHyperskillAnalyticsEvent( +class ProfileBadgeModalHiddenHyperskillAnalyticEvent( private val badgeKind: BadgeKind ) : HyperskillAnalyticEvent( HyperskillAnalyticRoute.Profile(), diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileBadgeModalShownHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileBadgeModalShownHyperskillAnalyticEvent.kt index dae21dc972..335b31391b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileBadgeModalShownHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileBadgeModalShownHyperskillAnalyticEvent.kt @@ -8,7 +8,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar import org.hyperskill.app.badges.domain.model.BadgeKind /** - * Represents show of the badge detailed modal in profile analytics event. + * Represents show of the badge detailed modal in profile analytic event. * * JSON payload: * ``` diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgeCardHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgeCardHyperskillAnalyticEvent.kt similarity index 92% rename from shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgeCardHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgeCardHyperskillAnalyticEvent.kt index d0155afe41..91a87c6faa 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgeCardHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgeCardHyperskillAnalyticEvent.kt @@ -8,7 +8,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar import org.hyperskill.app.badges.domain.model.BadgeKind /** - * Represents click on the badge in profile analytics event. + * Represents click on the badge in profile analytic event. * * JSON payload: * ``` @@ -23,9 +23,10 @@ import org.hyperskill.app.badges.domain.model.BadgeKind * } * } * ``` + * * @see HyperskillAnalyticEvent */ -class ProfileClickedBadgeCardHyperskillAnalyticsEvent( +class ProfileClickedBadgeCardHyperskillAnalyticEvent( private val badgeKind: BadgeKind, private val isLocked: Boolean ) : HyperskillAnalyticEvent( diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgesVisibilityButtonHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgesVisibilityButtonHyperskillAnalyticEvent.kt similarity index 94% rename from shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgesVisibilityButtonHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgesVisibilityButtonHyperskillAnalyticEvent.kt index fa9994c6c8..e5e61c4ec6 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgesVisibilityButtonHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/analytic/badges/ProfileClickedBadgesVisibilityButtonHyperskillAnalyticEvent.kt @@ -8,7 +8,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar import org.hyperskill.app.profile.presentation.ProfileFeature /** - * Represents click on the showAll or showLess badges button in profile analytics event. + * Represents click on the showAll or showLess badges button in profile analytic event. * * JSON payload: * ``` @@ -22,9 +22,10 @@ import org.hyperskill.app.profile.presentation.ProfileFeature * } * } * ``` + * * @see HyperskillAnalyticEvent */ -class ProfileClickedBadgesVisibilityButtonHyperskillAnalyticsEvent( +class ProfileClickedBadgesVisibilityButtonHyperskillAnalyticEvent( private val visibilityButton: ProfileFeature.Message.BadgesVisibilityButton ) : HyperskillAnalyticEvent( HyperskillAnalyticRoute.Profile(), diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/interactor/ProfileInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/interactor/ProfileInteractor.kt deleted file mode 100644 index b54059e68b..0000000000 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/interactor/ProfileInteractor.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.hyperskill.app.profile.domain.interactor - -import kotlinx.coroutines.flow.SharedFlow -import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository - -/* ktlint-disable */ -@Deprecated("ProfileInteractor is going to be removed. To access solvedStepsSharedFlow use SubmissionRepository directly.") -class ProfileInteractor( - submissionRepository: SubmissionRepository -) { - @Deprecated( - "Use submissionRepository.solvedStepsMutableSharedFlow instead.", - replaceWith = ReplaceWith( - "submissionRepository.solvedStepsMutableSharedFlow", - "import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository" - ) - ) - val solvedStepsSharedFlow: SharedFlow = submissionRepository.solvedStepsMutableSharedFlow -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/model/FeatureKeys.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/model/FeatureKeys.kt index 89b174dd1f..4ba98c106b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/model/FeatureKeys.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/model/FeatureKeys.kt @@ -1,11 +1,15 @@ package org.hyperskill.app.profile.domain.model -object FeatureKeys { +internal object FeatureKeys { const val RECOMMENDATIONS_JAVA_PROJECTS = "recommendations.java_projects" const val RECOMMENDATIONS_KOTLIN_PROJECTS = "recommendations.kotlin_projects" const val RECOMMENDATIONS_PYTHON_PROJECTS = "recommendations.python_projects" const val FREEMIUM_INCREASE_LIMITS_FOR_FIRST_STEP_COMPLETION = "freemium.increase_limits_for_first_step_completion" + const val FREEMIUM_WRONG_SUBMISSION_CHARGE_LIMITS = "freemium.wrong_submission_charge_limits" const val LEARNING_PATH_DIVIDED_TRACK_TOPICS = "learning_path.divided_track_topics" const val MOBILE_LEADERBOARDS = "mobile_leaderboards" const val MOBILE_INTERVIEW_PREPARATION = "mobile.interview_preparation" + const val MOBILE_ONLY_SUBSCRIPTION = "mobile.mobile_only_subscription" + const val MOBILE_USERS_QUESTIONNAIRE = "mobile.users_questionnaire" + const val MOBILE_SHORT_THEORY = "mobile.short_theory" } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/model/FeaturesMap.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/model/FeaturesMap.kt index 24ec705f55..b72575ca3f 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/model/FeaturesMap.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/model/FeaturesMap.kt @@ -14,6 +14,9 @@ val FeaturesMap.isRecommendationsPythonProjectsFeatureEnabled: Boolean val FeaturesMap.isFreemiumIncreaseLimitsForFirstStepCompletionEnabled: Boolean get() = get(FeatureKeys.FREEMIUM_INCREASE_LIMITS_FOR_FIRST_STEP_COMPLETION) ?: false +val FeaturesMap.isFreemiumWrongSubmissionChargeLimitsEnabled: Boolean + get() = get(FeatureKeys.FREEMIUM_WRONG_SUBMISSION_CHARGE_LIMITS) ?: false + val FeaturesMap.isLearningPathDividedTrackTopicsEnabled: Boolean get() = get(FeatureKeys.LEARNING_PATH_DIVIDED_TRACK_TOPICS) ?: false @@ -21,4 +24,13 @@ val FeaturesMap.isMobileLeaderboardsEnabled: Boolean get() = get(FeatureKeys.MOBILE_LEADERBOARDS) ?: false val FeaturesMap.isMobileInterviewPreparationEnabled: Boolean - get() = get(FeatureKeys.MOBILE_INTERVIEW_PREPARATION) ?: false \ No newline at end of file + get() = get(FeatureKeys.MOBILE_INTERVIEW_PREPARATION) ?: false + +val FeaturesMap.isMobileOnlySubscriptionEnabled: Boolean + get() = get(FeatureKeys.MOBILE_ONLY_SUBSCRIPTION) ?: false + +val FeaturesMap.isMobileUsersQuestionnaireEnabled: Boolean + get() = get(FeatureKeys.MOBILE_USERS_QUESTIONNAIRE) ?: false + +val FeaturesMap.isMobileShortTheoryEnabled: Boolean + get() = get(FeatureKeys.MOBILE_SHORT_THEORY) ?: false \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/repository/CurrentProfileStateRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/repository/CurrentProfileStateRepository.kt index 900ec6fe3b..9db7bbce77 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/repository/CurrentProfileStateRepository.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/domain/repository/CurrentProfileStateRepository.kt @@ -5,10 +5,16 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import org.hyperskill.app.core.domain.repository.StateRepository import org.hyperskill.app.profile.domain.model.Profile +import org.hyperskill.app.profile.domain.model.isFreemiumWrongSubmissionChargeLimitsEnabled interface CurrentProfileStateRepository : StateRepository internal fun CurrentProfileStateRepository.observeHypercoinsBalance(): Flow = changes .map { it.gamification.hypercoinsBalance } - .distinctUntilChanged() \ No newline at end of file + .distinctUntilChanged() + +internal suspend fun CurrentProfileStateRepository.isFreemiumWrongSubmissionChargeLimitsEnabled(): Boolean = + getState(forceUpdate = false) + .map { it.features.isFreemiumWrongSubmissionChargeLimitsEnabled } + .getOrDefault(defaultValue = false) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileComponentImpl.kt index 32a064df7b..948df1e447 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileComponentImpl.kt @@ -5,10 +5,11 @@ import org.hyperskill.app.profile.presentation.ProfileFeature import org.hyperskill.app.profile.view.BadgesViewStateMapper import ru.nobird.app.presentation.redux.feature.Feature -class ProfileComponentImpl(private val appGraph: AppGraph) : ProfileComponent { +internal class ProfileComponentImpl( + private val appGraph: AppGraph +) : ProfileComponent { override val profileFeature: Feature get() = ProfileFeatureBuilder.build( - profileInteractor = appGraph.profileDataComponent.profileInteractor, currentProfileStateRepository = appGraph.profileDataComponent.currentProfileStateRepository, streaksInteractor = appGraph.buildStreaksDataComponent().streaksInteractor, productsInteractor = appGraph.buildProductsDataComponent().productsInteractor, @@ -18,6 +19,7 @@ class ProfileComponentImpl(private val appGraph: AppGraph) : ProfileComponent { urlPathProcessor = appGraph.buildMagicLinksDataComponent().urlPathProcessor, streakFlow = appGraph.streakFlowDataComponent.streakFlow, dailyStudyRemindersEnabledFlow = appGraph.notificationFlowDataComponent.dailyStudyRemindersEnabledFlow, + stepCompletedFlow = appGraph.stepCompletionFlowDataComponent.stepCompletedFlow, badgesRepository = appGraph.buildBadgesDataComponent().badgesRepository, logger = appGraph.loggerComponent.logger, buildVariant = appGraph.commonComponent.buildKonfig.buildVariant diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileDataComponent.kt index 71e02b6eeb..2a81198105 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileDataComponent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileDataComponent.kt @@ -1,12 +1,9 @@ package org.hyperskill.app.profile.injection -import org.hyperskill.app.profile.domain.interactor.ProfileInteractor import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.profile.domain.repository.ProfileRepository interface ProfileDataComponent { val profileRepository: ProfileRepository val currentProfileStateRepository: CurrentProfileStateRepository - @Deprecated("Use submissionDataComponent.submissionRepository instead.") - val profileInteractor: ProfileInteractor } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileDataComponentImpl.kt index 289d8375f6..965bae52cd 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileDataComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileDataComponentImpl.kt @@ -7,16 +7,13 @@ import org.hyperskill.app.profile.data.repository.CurrentProfileStateRepositoryI import org.hyperskill.app.profile.data.repository.ProfileRepositoryImpl import org.hyperskill.app.profile.data.source.CurrentProfileStateHolder import org.hyperskill.app.profile.data.source.ProfileRemoteDataSource -import org.hyperskill.app.profile.domain.interactor.ProfileInteractor import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.profile.domain.repository.ProfileRepository import org.hyperskill.app.profile.remote.ProfileRemoteDataSourceImpl -import org.hyperskill.app.step_quiz.injection.SubmissionDataComponent -class ProfileDataComponentImpl( +internal class ProfileDataComponentImpl( networkComponent: NetworkComponent, - commonComponent: CommonComponent, - private val submissionDataComponent: SubmissionDataComponent + commonComponent: CommonComponent ) : ProfileDataComponent { private val profileRemoteDataSource: ProfileRemoteDataSource by lazy { @@ -41,9 +38,4 @@ class ProfileDataComponentImpl( stateHolder = currentProfileStateHolder ) } - - override val profileInteractor: ProfileInteractor - get() = ProfileInteractor( - submissionDataComponent.submissionRepository - ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileFeatureBuilder.kt index edcdad4682..4e80cc2074 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/injection/ProfileFeatureBuilder.kt @@ -10,7 +10,6 @@ import org.hyperskill.app.magic_links.domain.interactor.UrlPathProcessor import org.hyperskill.app.notification.local.domain.flow.DailyStudyRemindersEnabledFlow import org.hyperskill.app.notification.local.domain.interactor.NotificationInteractor import org.hyperskill.app.products.domain.interactor.ProductsInteractor -import org.hyperskill.app.profile.domain.interactor.ProfileInteractor import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.profile.presentation.ProfileActionDispatcher import org.hyperskill.app.profile.presentation.ProfileFeature.Action @@ -18,17 +17,17 @@ import org.hyperskill.app.profile.presentation.ProfileFeature.Message import org.hyperskill.app.profile.presentation.ProfileFeature.State import org.hyperskill.app.profile.presentation.ProfileReducer import org.hyperskill.app.sentry.domain.interactor.SentryInteractor +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow import org.hyperskill.app.streaks.domain.flow.StreakFlow import org.hyperskill.app.streaks.domain.interactor.StreaksInteractor import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher import ru.nobird.app.presentation.redux.feature.Feature import ru.nobird.app.presentation.redux.feature.ReduxFeature -object ProfileFeatureBuilder { +internal object ProfileFeatureBuilder { private const val LOG_TAG = "ProfileFeature" fun build( - profileInteractor: ProfileInteractor, currentProfileStateRepository: CurrentProfileStateRepository, streaksInteractor: StreaksInteractor, productsInteractor: ProductsInteractor, @@ -38,24 +37,25 @@ object ProfileFeatureBuilder { urlPathProcessor: UrlPathProcessor, streakFlow: StreakFlow, dailyStudyRemindersEnabledFlow: DailyStudyRemindersEnabledFlow, + stepCompletedFlow: StepCompletedFlow, badgesRepository: BadgesRepository, logger: Logger, buildVariant: BuildVariant ): Feature { val profileReducer = ProfileReducer().wrapWithLogger(buildVariant, logger, LOG_TAG) val profileActionDispatcher = ProfileActionDispatcher( - ActionDispatcherOptions(), - profileInteractor, - currentProfileStateRepository, - streaksInteractor, - productsInteractor, - analyticInteractor, - sentryInteractor, - notificationInteractor, - urlPathProcessor, - streakFlow, - dailyStudyRemindersEnabledFlow, - badgesRepository + config = ActionDispatcherOptions(), + currentProfileStateRepository = currentProfileStateRepository, + streaksInteractor = streaksInteractor, + productsInteractor = productsInteractor, + analyticInteractor = analyticInteractor, + sentryInteractor = sentryInteractor, + notificationInteractor = notificationInteractor, + urlPathProcessor = urlPathProcessor, + streakFlow = streakFlow, + dailyStudyRemindersEnabledFlow = dailyStudyRemindersEnabledFlow, + stepCompletedFlow = stepCompletedFlow, + badgesRepository = badgesRepository ) return ReduxFeature(State.Idle, profileReducer) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileActionDispatcher.kt index 52bca2ec93..e19a494f06 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileActionDispatcher.kt @@ -15,21 +15,20 @@ import org.hyperskill.app.notification.local.domain.flow.DailyStudyRemindersEnab import org.hyperskill.app.notification.local.domain.interactor.NotificationInteractor import org.hyperskill.app.products.domain.interactor.ProductsInteractor import org.hyperskill.app.products.domain.model.Product -import org.hyperskill.app.profile.domain.interactor.ProfileInteractor import org.hyperskill.app.profile.domain.model.copy import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.profile.presentation.ProfileFeature.Action import org.hyperskill.app.profile.presentation.ProfileFeature.Message import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow import org.hyperskill.app.streaks.domain.flow.StreakFlow import org.hyperskill.app.streaks.domain.interactor.StreaksInteractor import org.hyperskill.app.streaks.domain.model.Streak import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher -class ProfileActionDispatcher( +internal class ProfileActionDispatcher( config: ActionDispatcherOptions, - profileInteractor: ProfileInteractor, private val currentProfileStateRepository: CurrentProfileStateRepository, private val streaksInteractor: StreaksInteractor, private val productsInteractor: ProductsInteractor, @@ -39,11 +38,12 @@ class ProfileActionDispatcher( private val urlPathProcessor: UrlPathProcessor, private val streakFlow: StreakFlow, dailyStudyRemindersEnabledFlow: DailyStudyRemindersEnabledFlow, + stepCompletedFlow: StepCompletedFlow, private val badgesRepository: BadgesRepository ) : CoroutineActionDispatcher(config.createConfig()) { init { - profileInteractor.solvedStepsSharedFlow + stepCompletedFlow.observe() .onEach { onNewMessage(Message.StepQuizSolved) } .launchIn(actionScope) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileReducer.kt index 59e00041ef..00b605635d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile/presentation/ProfileReducer.kt @@ -7,10 +7,10 @@ import org.hyperskill.app.profile.domain.analytic.ProfileClickedPullToRefreshHyp import org.hyperskill.app.profile.domain.analytic.ProfileClickedSettingsHyperskillAnalyticEvent import org.hyperskill.app.profile.domain.analytic.ProfileClickedViewFullProfileHyperskillAnalyticEvent import org.hyperskill.app.profile.domain.analytic.ProfileViewedHyperskillAnalyticEvent -import org.hyperskill.app.profile.domain.analytic.badges.ProfileBadgeModalHiddenHyperskillAnalyticsEvent +import org.hyperskill.app.profile.domain.analytic.badges.ProfileBadgeModalHiddenHyperskillAnalyticEvent import org.hyperskill.app.profile.domain.analytic.badges.ProfileBadgeModalShownHyperskillAnalyticEvent -import org.hyperskill.app.profile.domain.analytic.badges.ProfileClickedBadgeCardHyperskillAnalyticsEvent -import org.hyperskill.app.profile.domain.analytic.badges.ProfileClickedBadgesVisibilityButtonHyperskillAnalyticsEvent +import org.hyperskill.app.profile.domain.analytic.badges.ProfileClickedBadgeCardHyperskillAnalyticEvent +import org.hyperskill.app.profile.domain.analytic.badges.ProfileClickedBadgesVisibilityButtonHyperskillAnalyticEvent import org.hyperskill.app.profile.domain.analytic.streak_freeze.StreakFreezeAnalyticState import org.hyperskill.app.profile.domain.analytic.streak_freeze.StreakFreezeCardAnalyticAction import org.hyperskill.app.profile.domain.analytic.streak_freeze.StreakFreezeClickedCardActionHyperskillAnalyticEvent @@ -26,7 +26,7 @@ import ru.nobird.app.presentation.redux.reducer.StateReducer private typealias ReducerResult = Pair> -class ProfileReducer : StateReducer { +internal class ProfileReducer : StateReducer { override fun reduce(state: State, message: Message): ReducerResult = when (message) { is Message.Initialize -> { @@ -188,7 +188,7 @@ class ProfileReducer : StateReducer { is Message.BadgeModalHiddenEventMessage -> state to setOf( Action.LogAnalyticEvent( - ProfileBadgeModalHiddenHyperskillAnalyticsEvent(message.badgeKind) + ProfileBadgeModalHiddenHyperskillAnalyticEvent(message.badgeKind) ) ) is Message.ViewedEventMessage -> @@ -300,7 +300,7 @@ class ProfileReducer : StateReducer { ) ) to setOf( Action.LogAnalyticEvent( - ProfileClickedBadgesVisibilityButtonHyperskillAnalyticsEvent(message.visibilityButton) + ProfileClickedBadgesVisibilityButtonHyperskillAnalyticEvent(message.visibilityButton) ) ) } else { @@ -323,7 +323,7 @@ class ProfileReducer : StateReducer { state to setOf( showAction, Action.LogAnalyticEvent( - ProfileClickedBadgeCardHyperskillAnalyticsEvent( + ProfileClickedBadgeCardHyperskillAnalyticEvent( badgeKind = message.badgeKind, isLocked = message.badgeKind !in state.badgesState.badges.map { it.kind } ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/domain/analytic/ProfileSettingsClickedHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/domain/analytic/ProfileSettingsClickedHyperskillAnalyticEvent.kt index 36f6c28160..25793cba7e 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/domain/analytic/ProfileSettingsClickedHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/domain/analytic/ProfileSettingsClickedHyperskillAnalyticEvent.kt @@ -78,9 +78,25 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * "target": "delete_account" * } * ``` + * + * Click on the "Rate us in the App Store" button: + * ``` + * { + * "route": "/profile/settings", + * "action": "click", + * "part": "main", + * "target": "rate_us_in_app_store" + * } + * ``` + * * @see HyperskillAnalyticEvent */ class ProfileSettingsClickedHyperskillAnalyticEvent( - part: HyperskillAnalyticPart = HyperskillAnalyticPart.MAIN, - target: HyperskillAnalyticTarget -) : HyperskillAnalyticEvent(HyperskillAnalyticRoute.Profile.Settings(), HyperskillAnalyticAction.CLICK, part, target) \ No newline at end of file + target: HyperskillAnalyticTarget, + part: HyperskillAnalyticPart = HyperskillAnalyticPart.MAIN +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Profile.Settings(), + HyperskillAnalyticAction.CLICK, + part, + target +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/injection/ProfileSettingsComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/injection/ProfileSettingsComponent.kt index ef8dff6022..43eeddbc11 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/injection/ProfileSettingsComponent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/injection/ProfileSettingsComponent.kt @@ -1,11 +1,12 @@ package org.hyperskill.app.profile_settings.injection import org.hyperskill.app.profile_settings.domain.interactor.ProfileSettingsInteractor -import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Action +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Message +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.ViewState import ru.nobird.app.presentation.redux.feature.Feature interface ProfileSettingsComponent { - val profileSettingsFeature: Feature< - ProfileSettingsFeature.State, ProfileSettingsFeature.Message, ProfileSettingsFeature.Action> + val profileSettingsFeature: Feature val profileSettingsInteractor: ProfileSettingsInteractor } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/injection/ProfileSettingsComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/injection/ProfileSettingsComponentImpl.kt index 818b84a18a..303fbf04ee 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/injection/ProfileSettingsComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/injection/ProfileSettingsComponentImpl.kt @@ -1,41 +1,45 @@ package org.hyperskill.app.profile_settings.injection import org.hyperskill.app.core.injection.AppGraph -import org.hyperskill.app.magic_links.domain.interactor.UrlPathProcessor import org.hyperskill.app.profile_settings.cache.ProfileSettingsCacheDataSourceImpl import org.hyperskill.app.profile_settings.data.repository.ProfileSettingsRepositoryImpl import org.hyperskill.app.profile_settings.data.source.ProfileSettingsCacheDataSource import org.hyperskill.app.profile_settings.domain.interactor.ProfileSettingsInteractor import org.hyperskill.app.profile_settings.domain.repository.ProfileSettingsRepository -import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Action +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Message +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.ViewState import ru.nobird.app.presentation.redux.feature.Feature -class ProfileSettingsComponentImpl(private val appGraph: AppGraph) : ProfileSettingsComponent { +internal class ProfileSettingsComponentImpl( + private val appGraph: AppGraph +) : ProfileSettingsComponent { private val profileSettingsCacheDataSource: ProfileSettingsCacheDataSource = ProfileSettingsCacheDataSourceImpl( appGraph.commonComponent.json, appGraph.commonComponent.settings ) + private val profileSettingsRepository: ProfileSettingsRepository = ProfileSettingsRepositoryImpl(profileSettingsCacheDataSource) + override val profileSettingsInteractor: ProfileSettingsInteractor = ProfileSettingsInteractor(profileSettingsRepository) - private val urlPathProcessor: UrlPathProcessor = - appGraph.buildMagicLinksDataComponent().urlPathProcessor - - override val profileSettingsFeature: Feature< - ProfileSettingsFeature.State, ProfileSettingsFeature.Message, ProfileSettingsFeature.Action> + override val profileSettingsFeature: Feature get() = ProfileSettingsFeatureBuilder.build( - profileSettingsInteractor, - appGraph.profileDataComponent.currentProfileStateRepository, - appGraph.analyticComponent.analyticInteractor, - appGraph.networkComponent.authorizationFlow, - appGraph.commonComponent.platform, - appGraph.commonComponent.userAgentInfo, - appGraph.commonComponent.resourceProvider, - urlPathProcessor, - appGraph.loggerComponent.logger, - appGraph.commonComponent.buildKonfig.buildVariant + profileSettingsInteractor = profileSettingsInteractor, + currentProfileStateRepository = appGraph.profileDataComponent.currentProfileStateRepository, + analyticInteractor = appGraph.analyticComponent.analyticInteractor, + authorizationFlow = appGraph.networkComponent.authorizationFlow, + platform = appGraph.commonComponent.platform, + userAgentInfo = appGraph.commonComponent.userAgentInfo, + resourceProvider = appGraph.commonComponent.resourceProvider, + urlPathProcessor = appGraph.buildMagicLinksDataComponent().urlPathProcessor, + currentSubscriptionStateRepository = appGraph.stateRepositoriesComponent.currentSubscriptionStateRepository, + purchaseInteractor = appGraph.buildPurchaseComponent().purchaseInteractor, + sentryInteractor = appGraph.sentryComponent.sentryInteractor, + logger = appGraph.loggerComponent.logger, + buildVariant = appGraph.commonComponent.buildKonfig.buildVariant ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/injection/ProfileSettingsFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/injection/ProfileSettingsFeatureBuilder.kt index be65fb3117..19654a8ba3 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/injection/ProfileSettingsFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/injection/ProfileSettingsFeatureBuilder.kt @@ -7,6 +7,7 @@ import org.hyperskill.app.auth.domain.model.UserDeauthorized import org.hyperskill.app.core.domain.BuildVariant import org.hyperskill.app.core.domain.platform.Platform import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.core.presentation.transformState import org.hyperskill.app.core.remote.UserAgentInfo import org.hyperskill.app.core.view.mapper.ResourceProvider import org.hyperskill.app.logging.presentation.wrapWithLogger @@ -17,12 +18,17 @@ import org.hyperskill.app.profile_settings.presentation.ProfileSettingsActionDis import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Action import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Message import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.State +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.ViewState import org.hyperskill.app.profile_settings.presentation.ProfileSettingsReducer +import org.hyperskill.app.profile_settings.view.ProfileSettingsViewStateMapper +import org.hyperskill.app.purchases.domain.interactor.PurchaseInteractor +import org.hyperskill.app.sentry.domain.interactor.SentryInteractor +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher import ru.nobird.app.presentation.redux.feature.Feature import ru.nobird.app.presentation.redux.feature.ReduxFeature -object ProfileSettingsFeatureBuilder { +internal object ProfileSettingsFeatureBuilder { private const val LOG_TAG = "ProfileSettingsFeature" fun build( @@ -34,23 +40,33 @@ object ProfileSettingsFeatureBuilder { userAgentInfo: UserAgentInfo, resourceProvider: ResourceProvider, urlPathProcessor: UrlPathProcessor, + currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, + purchaseInteractor: PurchaseInteractor, + sentryInteractor: SentryInteractor, logger: Logger, buildVariant: BuildVariant - ): Feature { + ): Feature { val profileSettingsReducer = ProfileSettingsReducer().wrapWithLogger(buildVariant, logger, LOG_TAG) val profileSettingsActionDispatcher = ProfileSettingsActionDispatcher( - ActionDispatcherOptions(), - profileSettingsInteractor, - currentProfileStateRepository, - analyticInteractor, - authorizationFlow, - platform, - userAgentInfo, - resourceProvider, - urlPathProcessor + config = ActionDispatcherOptions(), + profileSettingsInteractor = profileSettingsInteractor, + currentProfileStateRepository = currentProfileStateRepository, + analyticInteractor = analyticInteractor, + authorizationFlow = authorizationFlow, + platform = platform, + userAgentInfo = userAgentInfo, + resourceProvider = resourceProvider, + urlPathProcessor = urlPathProcessor, + currentSubscriptionStateRepository = currentSubscriptionStateRepository, + purchaseInteractor = purchaseInteractor, + sentryInteractor = sentryInteractor, + logger = logger.withTag(LOG_TAG) ) + val viewStateMapper = ProfileSettingsViewStateMapper(resourceProvider) + return ReduxFeature(State.Idle, profileSettingsReducer) .wrapWithActionDispatcher(profileSettingsActionDispatcher) + .transformState(viewStateMapper::map) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsActionDispatcher.kt index 395a7afdd6..12a6018bc5 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsActionDispatcher.kt @@ -1,6 +1,11 @@ package org.hyperskill.app.profile_settings.presentation +import co.touchlab.kermit.Logger +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import org.hyperskill.app.SharedResources.strings import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.auth.domain.model.UserDeauthorized @@ -10,14 +15,20 @@ import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.core.remote.UserAgentInfo import org.hyperskill.app.core.view.mapper.ResourceProvider import org.hyperskill.app.magic_links.domain.interactor.UrlPathProcessor +import org.hyperskill.app.profile.domain.model.isMobileOnlySubscriptionEnabled import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.profile_settings.domain.interactor.ProfileSettingsInteractor import org.hyperskill.app.profile_settings.domain.model.FeedbackEmailDataBuilder import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Action import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Message +import org.hyperskill.app.purchases.domain.interactor.PurchaseInteractor +import org.hyperskill.app.sentry.domain.interactor.SentryInteractor +import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder +import org.hyperskill.app.sentry.domain.withTransaction +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher -class ProfileSettingsActionDispatcher( +internal class ProfileSettingsActionDispatcher( config: ActionDispatcherOptions, private val profileSettingsInteractor: ProfileSettingsInteractor, private val currentProfileStateRepository: CurrentProfileStateRepository, @@ -26,14 +37,26 @@ class ProfileSettingsActionDispatcher( private val platform: Platform, private val userAgentInfo: UserAgentInfo, private val resourceProvider: ResourceProvider, - private val urlPathProcessor: UrlPathProcessor + private val urlPathProcessor: UrlPathProcessor, + private val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, + private val purchaseInteractor: PurchaseInteractor, + private val sentryInteractor: SentryInteractor, + private val logger: Logger ) : CoroutineActionDispatcher(config.createConfig()) { + + init { + currentSubscriptionStateRepository + .changes + .onEach { subscription -> + onNewMessage(Message.OnSubscriptionChanged(subscription)) + } + .launchIn(actionScope) + } + override suspend fun doSuspendableAction(action: Action) { when (action) { - is Action.FetchProfileSettings -> { - val profileSettings = profileSettingsInteractor.getProfileSettings() - onNewMessage(Message.ProfileSettingsSuccess(profileSettings)) - } + is Action.FetchProfileSettings -> + handleFetchProfileSettings(platform.isSubscriptionPurchaseEnabled, ::onNewMessage) is Action.ChangeTheme -> profileSettingsInteractor.changeTheme(action.theme) is Action.SignOut -> @@ -72,4 +95,64 @@ class ProfileSettingsActionDispatcher( onNewMessage(Message.GetMagicLinkReceiveFailure) } ) + + private suspend fun handleFetchProfileSettings( + isSubscriptionPurchaseEnabled: Boolean, + onNewMessage: (Message) -> Unit + ) { + val message = if (isSubscriptionPurchaseEnabled && isMobileOnlySubscriptionEnabled()) { + sentryInteractor.withTransaction( + HyperskillSentryTransactionBuilder.buildProfileSettingsFeatureFetchSubscription(), + onError = { + Message.ProfileSettingsSuccess( + profileSettings = profileSettingsInteractor.getProfileSettings() + ) + } + ) { + fetchProfileSettingsWithSubscription() + } + } else { + Message.ProfileSettingsSuccess( + profileSettings = profileSettingsInteractor.getProfileSettings() + ) + } + onNewMessage(message) + } + + private suspend fun fetchProfileSettingsWithSubscription(): Message.ProfileSettingsSuccess = + coroutineScope { + val subscriptionDeferred = async { + currentSubscriptionStateRepository.getState(forceUpdate = true) + } + val priceDeferred = async { + purchaseInteractor.getFormattedMobileOnlySubscriptionPrice() + } + Message.ProfileSettingsSuccess( + profileSettings = profileSettingsInteractor.getProfileSettings(), + subscription = subscriptionDeferred + .await() + .onFailure { + logger.e(it) { + "Failed to load subscription" + } + } + .getOrNull(), + mobileOnlyFormattedPrice = priceDeferred + .await() + .onFailure { + logger.e(it) { + "Failed to load subscription price" + } + } + .getOrNull() + ) + } + + private suspend fun isMobileOnlySubscriptionEnabled(): Boolean = + currentProfileStateRepository + .getState() + .getOrNull() + ?.features + ?.isMobileOnlySubscriptionEnabled + ?: false } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsFeature.kt index f345e3b3e7..e3aaf1d198 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsFeature.kt @@ -2,12 +2,14 @@ package org.hyperskill.app.profile_settings.presentation import org.hyperskill.app.analytic.domain.model.AnalyticEvent import org.hyperskill.app.core.domain.url.HyperskillUrlPath +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource import org.hyperskill.app.profile_settings.domain.model.FeedbackEmailData import org.hyperskill.app.profile_settings.domain.model.ProfileSettings import org.hyperskill.app.profile_settings.domain.model.Theme +import org.hyperskill.app.subscriptions.domain.model.Subscription -interface ProfileSettingsFeature { - sealed interface State { +object ProfileSettingsFeature { + internal sealed interface State { object Idle : State object Loading : State @@ -16,16 +18,34 @@ interface ProfileSettingsFeature { */ data class Content( val profileSettings: ProfileSettings, + val subscription: Subscription?, + val mobileOnlyFormattedPrice: String?, val isLoadingMagicLink: Boolean = false ) : State + } + + sealed interface ViewState { + object Idle : ViewState + object Loading : ViewState - object Error : State + data class Content( + val profileSettings: ProfileSettings, + val subscriptionState: SubscriptionState?, + val isLoadingMagicLink: Boolean + ) : ViewState { + data class SubscriptionState( + val description: String + ) + } } sealed interface Message { - data class InitMessage(val forceUpdate: Boolean = false) : Message - data class ProfileSettingsSuccess(val profileSettings: ProfileSettings) : Message - object ProfileSettingsError : Message + object InitMessage : Message + data class ProfileSettingsSuccess( + val profileSettings: ProfileSettings, + val subscription: Subscription? = null, + val mobileOnlyFormattedPrice: String? = null + ) : Message data class ThemeChanged(val theme: Theme) : Message object SignOutConfirmed : Message object DismissScreen : Message @@ -38,6 +58,12 @@ interface ProfileSettingsFeature { data class DeleteAccountNoticeHidden(val isConfirmed: Boolean) : Message + object SubscriptionDetailsClicked : Message + + data class OnSubscriptionChanged( + val subscription: Subscription + ) : Message + /** * Analytic */ @@ -54,6 +80,10 @@ interface ProfileSettingsFeature { data class SignOutNoticeHiddenEventMessage(val isConfirmed: Boolean) : Message object ClickedDeleteAccountEventMessage : Message object DeleteAccountNoticeShownEventMessage : Message + + object ClickedRateUsInAppStoreEventMessage : Message + + object ClickedRateUsInPlayStoreEventMessage : Message } sealed interface Action { @@ -75,6 +105,10 @@ interface ProfileSettingsFeature { sealed interface NavigateTo : ViewAction { object ParentScreen : NavigateTo + + object SubscriptionManagement : NavigateTo + + data class Paywall(val paywallTransitionSource: PaywallTransitionSource) : NavigateTo } } } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsReducer.kt index a9cb159c76..b19a118236 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/presentation/ProfileSettingsReducer.kt @@ -3,6 +3,7 @@ package org.hyperskill.app.profile_settings.presentation import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget import org.hyperskill.app.core.domain.url.HyperskillUrlPath +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource import org.hyperskill.app.profile_settings.domain.analytic.ProfileSettingsClickedHyperskillAnalyticEvent import org.hyperskill.app.profile_settings.domain.analytic.ProfileSettingsDeleteAccountNoticeHiddenHyperskillAnalyticEvent import org.hyperskill.app.profile_settings.domain.analytic.ProfileSettingsDeleteAccountNoticeShownHyperskillAnalyticEvent @@ -12,27 +13,32 @@ import org.hyperskill.app.profile_settings.domain.analytic.ProfileSettingsViewed import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Action import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.Message import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.State +import org.hyperskill.app.subscriptions.domain.model.SubscriptionType import ru.nobird.app.presentation.redux.reducer.StateReducer -class ProfileSettingsReducer : StateReducer { +private typealias ReducerResult = Pair> + +internal class ProfileSettingsReducer : StateReducer { override fun reduce(state: State, message: Message): Pair> = when (message) { is Message.InitMessage -> { - if (state is State.Idle || - (message.forceUpdate && (state is State.Content || state is State.Error)) - ) { + if (state is State.Idle) { State.Loading to setOf(Action.FetchProfileSettings) } else { null } } is Message.ProfileSettingsSuccess -> - State.Content(message.profileSettings) to emptySet() - is Message.ProfileSettingsError -> - State.Error to emptySet() + State.Content( + profileSettings = message.profileSettings, + subscription = message.subscription, + mobileOnlyFormattedPrice = message.mobileOnlyFormattedPrice + ) to emptySet() + is Message.OnSubscriptionChanged -> + handleSubscriptionChanged(state, message) is Message.ThemeChanged -> if (state is State.Content) { - State.Content(state.profileSettings.copy(theme = message.theme)) to + state.copy(state.profileSettings.copy(theme = message.theme)) to setOf(Action.ChangeTheme(message.theme)) } else { null @@ -55,8 +61,8 @@ class ProfileSettingsReducer : StateReducer { state to setOf( Action.LogAnalyticEvent( ProfileSettingsClickedHyperskillAnalyticEvent( - HyperskillAnalyticPart.HEAD, - HyperskillAnalyticTarget.DONE + target = HyperskillAnalyticTarget.DONE, + part = HyperskillAnalyticPart.HEAD ) ) ) @@ -121,7 +127,7 @@ class ProfileSettingsReducer : StateReducer { Action.LogAnalyticEvent(ProfileSettingsDeleteAccountNoticeShownHyperskillAnalyticEvent()) ) is Message.DeleteAccountNoticeHidden -> { - val analyticsAction = Action.LogAnalyticEvent( + val logAnalyticEventAction = Action.LogAnalyticEvent( ProfileSettingsDeleteAccountNoticeHiddenHyperskillAnalyticEvent( message.isConfirmed ) @@ -129,12 +135,28 @@ class ProfileSettingsReducer : StateReducer { if (message.isConfirmed && state is State.Content) { state.copy(isLoadingMagicLink = true) to setOf( Action.GetMagicLink(HyperskillUrlPath.DeleteAccount()), - analyticsAction + logAnalyticEventAction ) } else { - state to setOf(analyticsAction) + state to setOf(logAnalyticEventAction) } } + Message.ClickedRateUsInAppStoreEventMessage -> + state to setOf( + Action.LogAnalyticEvent( + ProfileSettingsClickedHyperskillAnalyticEvent( + target = HyperskillAnalyticTarget.RATE_US_IN_APP_STORE + ) + ) + ) + Message.ClickedRateUsInPlayStoreEventMessage -> + state to setOf( + Action.LogAnalyticEvent( + ProfileSettingsClickedHyperskillAnalyticEvent( + target = HyperskillAnalyticTarget.RATE_US_IN_PLAY_STORE + ) + ) + ) is Message.GetMagicLinkReceiveSuccess -> { if (state is State.Content) { state.copy(isLoadingMagicLink = false) to setOf(Action.ViewAction.OpenUrl(message.url)) @@ -149,5 +171,48 @@ class ProfileSettingsReducer : StateReducer { null } } + is Message.SubscriptionDetailsClicked -> + handleSubscriptionDetailsClicked(state) } ?: (state to emptySet()) + + private fun handleSubscriptionChanged( + state: State, + message: Message.OnSubscriptionChanged + ): ReducerResult = + if (state is State.Content) { + state.copy(subscription = message.subscription) to emptySet() + } else { + state to emptySet() + } + + private fun handleSubscriptionDetailsClicked( + state: State + ): ReducerResult = + if (state is State.Content) { + state to when (state.subscription?.type) { + SubscriptionType.MOBILE_ONLY -> + setOf( + Action.LogAnalyticEvent( + ProfileSettingsClickedHyperskillAnalyticEvent( + HyperskillAnalyticTarget.ACTIVE_SUBSCRIPTION_DETAILS + ) + ), + Action.ViewAction.NavigateTo.SubscriptionManagement + ) + SubscriptionType.FREEMIUM -> + setOf( + Action.LogAnalyticEvent( + ProfileSettingsClickedHyperskillAnalyticEvent( + HyperskillAnalyticTarget.SUBSCRIPTION_SUGGESTION_DETAILS + ) + ), + Action.ViewAction.NavigateTo.Paywall( + PaywallTransitionSource.PROFILE_SETTINGS + ) + ) + else -> emptySet() + } + } else { + state to emptySet() + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/view/ProfileSettingsViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/view/ProfileSettingsViewStateMapper.kt new file mode 100644 index 0000000000..0ffd1634fb --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/profile_settings/view/ProfileSettingsViewStateMapper.kt @@ -0,0 +1,54 @@ +package org.hyperskill.app.profile_settings.view + +import org.hyperskill.app.SharedResources +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature +import org.hyperskill.app.profile_settings.presentation.ProfileSettingsFeature.ViewState +import org.hyperskill.app.subscriptions.domain.model.SubscriptionType + +internal class ProfileSettingsViewStateMapper( + private val resourceProvider: ResourceProvider +) { + fun map(state: ProfileSettingsFeature.State): ViewState = + when (state) { + ProfileSettingsFeature.State.Idle -> ViewState.Idle + ProfileSettingsFeature.State.Loading -> ViewState.Loading + is ProfileSettingsFeature.State.Content -> mapContentState(state) + } + + private fun mapContentState(state: ProfileSettingsFeature.State.Content): ViewState.Content = + ViewState.Content( + profileSettings = state.profileSettings, + isLoadingMagicLink = state.isLoadingMagicLink, + subscriptionState = if (isSubscriptionVisible(state)) { + when (state.subscription?.type) { + SubscriptionType.MOBILE_ONLY -> + ViewState.Content.SubscriptionState( + resourceProvider.getString(SharedResources.strings.settings_subscription_mobile_only) + ) + SubscriptionType.FREEMIUM -> { + state.mobileOnlyFormattedPrice?.let { + ViewState.Content.SubscriptionState( + resourceProvider.getString( + SharedResources.strings.settings_subscription_mobile_only_suggestion, + state.mobileOnlyFormattedPrice + ) + ) + } + } + else -> null + } + } else { + null + } + ) + + private fun isSubscriptionVisible(state: ProfileSettingsFeature.State.Content): Boolean = + state.subscription != null && + state.mobileOnlyFormattedPrice != null && + when (state.subscription.type) { + SubscriptionType.FREEMIUM, + SubscriptionType.MOBILE_ONLY -> true + else -> false + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/progress_screen/presentation/ProgressScreenActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/progress_screen/presentation/ProgressScreenActionDispatcher.kt index 86fabbc42d..3481e5383d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/progress_screen/presentation/ProgressScreenActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/progress_screen/presentation/ProgressScreenActionDispatcher.kt @@ -139,8 +139,8 @@ internal class ProgressScreenActionDispatcher( trackId: Long, forceLoadFromRemote: Boolean ): Result = - coroutineScope { - kotlin.runCatching { + kotlin.runCatching { + coroutineScope { val trackDeferred = async { trackInteractor.getTrack(trackId, forceLoadFromRemote) } @@ -149,7 +149,7 @@ internal class ProgressScreenActionDispatcher( } TrackWithProgress( track = trackDeferred.await().getOrThrow(), - trackProgress = trackProgressDeferred.await().getOrThrow() ?: return@runCatching null + trackProgress = trackProgressDeferred.await().getOrThrow() ?: return@coroutineScope null ) } } @@ -158,8 +158,8 @@ internal class ProgressScreenActionDispatcher( projectId: Long, forceLoadFromRemote: Boolean ): Result = - coroutineScope { - kotlin.runCatching { + kotlin.runCatching { + coroutineScope { val projectDeferred = async { projectsRepository.getProject(projectId, forceLoadFromRemote) } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/progress_screen/view/ProgressScreenViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/progress_screen/view/ProgressScreenViewStateMapper.kt index f7fa49a563..de461e02e4 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/progress_screen/view/ProgressScreenViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/progress_screen/view/ProgressScreenViewStateMapper.kt @@ -5,7 +5,6 @@ import org.hyperskill.app.core.view.mapper.ResourceProvider import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.progress_screen.presentation.ProgressScreenFeature import org.hyperskill.app.progress_screen.view.ProgressScreenViewState.TrackProgressViewState.Content.AppliedTopicsState -import org.hyperskill.app.subscriptions.domain.model.isFreemium import org.hyperskill.app.track.domain.model.Track import org.hyperskill.app.track.domain.model.asLevelByProjectIdMap import ru.nobird.app.core.model.safeCast @@ -52,7 +51,9 @@ internal class ProgressScreenViewStateMapper( ): ProgressScreenViewState.TrackProgressViewState.Content { val track = trackProgressContent.trackWithProgress.track val trackProgress = trackProgressContent.trackWithProgress.trackProgress - val isProjectUnavailable = trackProgressContent.subscription.isFreemium || track.projects.isEmpty() + val isProjectUnavailable = + !trackProgressContent.subscription.type.isProjectInfoAvailable || + track.projects.isEmpty() return ProgressScreenViewState.TrackProgressViewState.Content( title = track.title, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/analytic/ProjectSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/analytic/ProjectSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticEvent.kt similarity index 98% rename from shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/analytic/ProjectSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/analytic/ProjectSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticEvent.kt index d08c6492c8..e1decaaa86 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/analytic/ProjectSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/analytic/ProjectSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticEvent.kt @@ -26,7 +26,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * * @see HyperskillAnalyticEvent */ -class ProjectSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticsEvent( +class ProjectSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticEvent( projectId: Long, trackId: Long ) : HyperskillAnalyticEvent( diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/analytic/ProjectSelectionDetailsClickedSelectThisProjectHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/analytic/ProjectSelectionDetailsClickedSelectThisProjectHyperskillAnalyticEvent.kt similarity index 98% rename from shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/analytic/ProjectSelectionDetailsClickedSelectThisProjectHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/analytic/ProjectSelectionDetailsClickedSelectThisProjectHyperskillAnalyticEvent.kt index de600af685..d86a80fa0b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/analytic/ProjectSelectionDetailsClickedSelectThisProjectHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/analytic/ProjectSelectionDetailsClickedSelectThisProjectHyperskillAnalyticEvent.kt @@ -26,7 +26,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * * @see HyperskillAnalyticEvent */ -class ProjectSelectionDetailsClickedSelectThisProjectHyperskillAnalyticsEvent( +class ProjectSelectionDetailsClickedSelectThisProjectHyperskillAnalyticEvent( projectId: Long, trackId: Long ) : HyperskillAnalyticEvent( diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/interactor/ProjectSelectionDetailsInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/interactor/ProjectSelectionDetailsInteractor.kt index 4ef8b70ea4..8b16572b88 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/interactor/ProjectSelectionDetailsInteractor.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/domain/interactor/ProjectSelectionDetailsInteractor.kt @@ -25,8 +25,8 @@ internal class ProjectSelectionDetailsInteractor( projectId: Long, forceLoadFromNetwork: Boolean ): Result = - coroutineScope { - kotlin.runCatching { + kotlin.runCatching { + coroutineScope { val trackDeferred = async { trackRepository.getTrack(trackId, forceLoadFromNetwork) } val projectDeferred = async { projectsRepository.getProject(projectId, forceLoadFromNetwork) } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/presentation/ProjectSelectionDetailsReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/presentation/ProjectSelectionDetailsReducer.kt index e8eb5d94ba..9e2df5d8f4 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/presentation/ProjectSelectionDetailsReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/project_selection/details/presentation/ProjectSelectionDetailsReducer.kt @@ -1,7 +1,7 @@ package org.hyperskill.app.project_selection.details.presentation -import org.hyperskill.app.project_selection.details.domain.analytic.ProjectSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticsEvent -import org.hyperskill.app.project_selection.details.domain.analytic.ProjectSelectionDetailsClickedSelectThisProjectHyperskillAnalyticsEvent +import org.hyperskill.app.project_selection.details.domain.analytic.ProjectSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticEvent +import org.hyperskill.app.project_selection.details.domain.analytic.ProjectSelectionDetailsClickedSelectThisProjectHyperskillAnalyticEvent import org.hyperskill.app.project_selection.details.domain.analytic.ProjectSelectionDetailsViewedHyperskillAnalyticEvent import org.hyperskill.app.project_selection.details.presentation.ProjectSelectionDetailsFeature.Action import org.hyperskill.app.project_selection.details.presentation.ProjectSelectionDetailsFeature.ContentState @@ -69,7 +69,7 @@ internal class ProjectSelectionDetailsReducer : StateReducer = + if (!purchaseManager.isConfigured()) { + runCatching { + purchaseManager.configure(userId) + } + } else { + purchaseManager.login(userId) + } + + suspend fun purchaseMobileOnlySubscription( + platformPurchaseParams: PlatformPurchaseParams + ): Result = + purchaseManager.purchase(MOBILE_ONLY_SUBSCRIPTION_PRODUCT_ID, platformPurchaseParams) + + suspend fun getManagementUrl(): Result = + purchaseManager.getManagementUrl() + + suspend fun getFormattedMobileOnlySubscriptionPrice(): Result = + purchaseManager.getFormattedProductPrice(MOBILE_ONLY_SUBSCRIPTION_PRODUCT_ID) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformPurchaseParams.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformPurchaseParams.kt new file mode 100644 index 0000000000..59f63d7177 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PlatformPurchaseParams.kt @@ -0,0 +1,3 @@ +package org.hyperskill.app.purchases.domain.model + +interface PlatformPurchaseParams \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PurchaseManager.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PurchaseManager.kt new file mode 100644 index 0000000000..512a74baf8 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PurchaseManager.kt @@ -0,0 +1,35 @@ +package org.hyperskill.app.purchases.domain.model + +/** + * Represents an interface that both platforms should implement. + */ +interface PurchaseManager { + + fun isConfigured(): Boolean + + /** + * Setups the payment sdk with provided [userId] + */ + fun configure(userId: Long) + + /** + * Identifies user in the payment sdk with provided [userId]. + * Must be called just after login event. + */ + suspend fun login(userId: Long): Result + + /** + * Makes purchase of the product with [productId]. + */ + suspend fun purchase( + productId: String, + platformPurchaseParams: PlatformPurchaseParams + ): Result + + suspend fun getManagementUrl(): Result + + /** + * Returns formatted product price with currency by [productId] + */ + suspend fun getFormattedProductPrice(productId: String): Result +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PurchaseResult.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PurchaseResult.kt new file mode 100644 index 0000000000..1ea0708fe0 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/domain/model/PurchaseResult.kt @@ -0,0 +1,94 @@ +package org.hyperskill.app.purchases.domain.model + +sealed interface PurchaseResult { + data class Succeed( + val orderId: String?, + val productIds: List + ) : PurchaseResult + + object CancelledByUser : PurchaseResult + + sealed interface Error : PurchaseResult { + + val message: String + + val underlyingErrorMessage: String? + + class ErrorWhileFetchingProduct( + val productId: String, + val originMessage: String, + override val underlyingErrorMessage: String? + ) : Error { + override val message: String + get() = "Error while fetching product with id=$productId" + } + + class NoProductFound( + val productId: String + ) : Error { + override val message: String + get() = "Can't find product with id=$productId" + + override val underlyingErrorMessage: String? + get() = null + } + + /** + * The receipt is already in use by another subscriber. + * Log in with the previous account or contact support + * to get your purchases transferred to regain access. + */ + class ReceiptAlreadyInUseError( + override val message: String, + override val underlyingErrorMessage: String? + ) : Error + + /** + * The purchase is pending and may be completed at a later time. + * This can happen when awaiting parental approval or going + * through extra authentication flows for credit cards in some countries. + */ + class PaymentPendingError( + override val message: String, + override val underlyingErrorMessage: String? + ) : Error + + /** + * Subscription is already purchased. Log in with the account + * that originally performed this purchase if you're using a different one. + */ + class ProductAlreadyPurchasedError( + override val message: String, + override val underlyingErrorMessage: String? + ) : Error + + /** + * Purchasing wasn't allowed, which is common if the card is declined + * or the purchase is not available in the country + * you're trying to purchase from. + */ + class PurchaseNotAllowedError( + override val message: String, + override val underlyingErrorMessage: String? + ) : Error + + /** + * There was a problem with the Google Play Store. + * This is a generic Google error, and there's not enough information + * to determine the cause. + */ + class StoreProblemError( + override val message: String, + override val underlyingErrorMessage: String? + ) : Error + + /** + * Some other kind of error. + * For example, configuration error or invalid user id error. + */ + data class OtherError( + override val message: String, + override val underlyingErrorMessage: String? + ) : Error + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/injection/PurchaseComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/injection/PurchaseComponent.kt new file mode 100644 index 0000000000..d648740106 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/injection/PurchaseComponent.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.purchases.injection + +import org.hyperskill.app.purchases.domain.interactor.PurchaseInteractor + +interface PurchaseComponent { + val purchaseInteractor: PurchaseInteractor +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/injection/PurchaseComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/injection/PurchaseComponentImpl.kt new file mode 100644 index 0000000000..b5b5942b2c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/purchases/injection/PurchaseComponentImpl.kt @@ -0,0 +1,12 @@ +package org.hyperskill.app.purchases.injection + +import org.hyperskill.app.purchases.domain.interactor.PurchaseInteractor +import org.hyperskill.app.purchases.domain.model.PurchaseManager + +class PurchaseComponentImpl( + purchaseManager: PurchaseManager +) : PurchaseComponent { + + override val purchaseInteractor: PurchaseInteractor = + PurchaseInteractor(purchaseManager) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/cache/RequestReviewCacheDataSourceImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/cache/RequestReviewCacheDataSourceImpl.kt new file mode 100644 index 0000000000..5fad942016 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/cache/RequestReviewCacheDataSourceImpl.kt @@ -0,0 +1,22 @@ +package org.hyperskill.app.request_review.cache + +import com.russhwolf.settings.Settings +import org.hyperskill.app.request_review.data.source.RequestReviewCacheDataSource + +internal class RequestReviewCacheDataSourceImpl( + private val settings: Settings +) : RequestReviewCacheDataSource { + override fun getLastRequestReviewTimestamp(): Long? = + settings.getLongOrNull(RequestReviewCacheKeyValues.LAST_REQUEST_REVIEW_TIMESTAMP) + + override fun setLastRequestReviewTimestamp(timestamp: Long) { + settings.putLong(RequestReviewCacheKeyValues.LAST_REQUEST_REVIEW_TIMESTAMP, timestamp) + } + + override fun getRequestReviewCount(): Int = + settings.getInt(RequestReviewCacheKeyValues.REQUEST_REVIEW_COUNT, 0) + + override fun incrementRequestReviewCount() { + settings.putInt(RequestReviewCacheKeyValues.REQUEST_REVIEW_COUNT, getRequestReviewCount() + 1) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/cache/RequestReviewCacheKeyValues.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/cache/RequestReviewCacheKeyValues.kt new file mode 100644 index 0000000000..bb6b938f7c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/cache/RequestReviewCacheKeyValues.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.request_review.cache + +internal object RequestReviewCacheKeyValues { + const val LAST_REQUEST_REVIEW_TIMESTAMP = "last_request_review_timestamp" + const val REQUEST_REVIEW_COUNT = "request_review_count" +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/data/repository/RequestReviewRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/data/repository/RequestReviewRepositoryImpl.kt new file mode 100644 index 0000000000..50154168b4 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/data/repository/RequestReviewRepositoryImpl.kt @@ -0,0 +1,22 @@ +package org.hyperskill.app.request_review.data.repository + +import org.hyperskill.app.request_review.data.source.RequestReviewCacheDataSource +import org.hyperskill.app.request_review.domain.repository.RequestReviewRepository + +internal class RequestReviewRepositoryImpl( + private val requestReviewCacheDataSource: RequestReviewCacheDataSource +) : RequestReviewRepository { + override fun getLastRequestReviewTimestamp(): Long? = + requestReviewCacheDataSource.getLastRequestReviewTimestamp() + + override fun setLastRequestReviewTimestamp(timestamp: Long) { + requestReviewCacheDataSource.setLastRequestReviewTimestamp(timestamp) + } + + override fun getRequestReviewCount(): Int = + requestReviewCacheDataSource.getRequestReviewCount() + + override fun incrementRequestReviewCount() { + requestReviewCacheDataSource.incrementRequestReviewCount() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/data/source/RequestReviewCacheDataSource.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/data/source/RequestReviewCacheDataSource.kt new file mode 100644 index 0000000000..892e624f76 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/data/source/RequestReviewCacheDataSource.kt @@ -0,0 +1,9 @@ +package org.hyperskill.app.request_review.data.source + +interface RequestReviewCacheDataSource { + fun getLastRequestReviewTimestamp(): Long? + fun setLastRequestReviewTimestamp(timestamp: Long) + + fun getRequestReviewCount(): Int + fun incrementRequestReviewCount() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/domain/interactor/RequestReviewInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/domain/interactor/RequestReviewInteractor.kt new file mode 100644 index 0000000000..206687b905 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/domain/interactor/RequestReviewInteractor.kt @@ -0,0 +1,40 @@ +package org.hyperskill.app.request_review.domain.interactor + +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlinx.datetime.Clock +import org.hyperskill.app.request_review.domain.repository.RequestReviewRepository +import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository + +class RequestReviewInteractor( + private val requestReviewRepository: RequestReviewRepository, + private val submissionRepository: SubmissionRepository +) { + companion object { + private const val SOLVED_STEPS_FREQUENCY_TO_REQUEST_REVIEW = 10 + private const val MAX_REQUEST_REVIEW_COUNT = 3 + private val ONE_WEEK_IN_MILLIS = 7.toDuration(DurationUnit.DAYS).inWholeMilliseconds + } + + fun shouldRequestReviewAfterStepSolved(): Boolean { + val solvedStepsCount = submissionRepository.getSolvedStepsCount() + val isPassedSolvedStepsFrequency = solvedStepsCount % SOLVED_STEPS_FREQUENCY_TO_REQUEST_REVIEW == 0L + if (!isPassedSolvedStepsFrequency) { + return false + } + + val requestReviewCount = requestReviewRepository.getRequestReviewCount() + if (requestReviewCount >= MAX_REQUEST_REVIEW_COUNT) { + return false + } + + // Request review once a week + val lastRequestReviewTimestamp = requestReviewRepository.getLastRequestReviewTimestamp() ?: return true + return (lastRequestReviewTimestamp + ONE_WEEK_IN_MILLIS) <= Clock.System.now().toEpochMilliseconds() + } + + fun handleRequestReviewPerformed() { + requestReviewRepository.incrementRequestReviewCount() + requestReviewRepository.setLastRequestReviewTimestamp(Clock.System.now().toEpochMilliseconds()) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/domain/repository/RequestReviewRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/domain/repository/RequestReviewRepository.kt new file mode 100644 index 0000000000..b5f264274f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/domain/repository/RequestReviewRepository.kt @@ -0,0 +1,9 @@ +package org.hyperskill.app.request_review.domain.repository + +interface RequestReviewRepository { + fun getLastRequestReviewTimestamp(): Long? + fun setLastRequestReviewTimestamp(timestamp: Long) + + fun getRequestReviewCount(): Int + fun incrementRequestReviewCount() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/injection/RequestReviewDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/injection/RequestReviewDataComponent.kt new file mode 100644 index 0000000000..c7ff6671c5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/injection/RequestReviewDataComponent.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.request_review.injection + +import org.hyperskill.app.request_review.domain.interactor.RequestReviewInteractor + +interface RequestReviewDataComponent { + val requestReviewInteractor: RequestReviewInteractor +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/injection/RequestReviewDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/injection/RequestReviewDataComponentImpl.kt new file mode 100644 index 0000000000..0d4718fc2c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/injection/RequestReviewDataComponentImpl.kt @@ -0,0 +1,24 @@ +package org.hyperskill.app.request_review.injection + +import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.request_review.cache.RequestReviewCacheDataSourceImpl +import org.hyperskill.app.request_review.data.repository.RequestReviewRepositoryImpl +import org.hyperskill.app.request_review.data.source.RequestReviewCacheDataSource +import org.hyperskill.app.request_review.domain.interactor.RequestReviewInteractor +import org.hyperskill.app.request_review.domain.repository.RequestReviewRepository + +internal class RequestReviewDataComponentImpl( + private val appGraph: AppGraph +) : RequestReviewDataComponent { + private val requestReviewCacheDataSource: RequestReviewCacheDataSource = + RequestReviewCacheDataSourceImpl(appGraph.commonComponent.settings) + + private val requestReviewRepository: RequestReviewRepository = + RequestReviewRepositoryImpl(requestReviewCacheDataSource) + + override val requestReviewInteractor: RequestReviewInteractor + get() = RequestReviewInteractor( + requestReviewRepository = requestReviewRepository, + submissionRepository = appGraph.buildSubmissionDataComponent().submissionRepository + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/domain/analytic/RequestReviewModalClickHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/domain/analytic/RequestReviewModalClickHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..70ff44ef6e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/domain/analytic/RequestReviewModalClickHyperskillAnalyticEvent.kt @@ -0,0 +1,62 @@ +package org.hyperskill.app.request_review.modal.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents a common click analytic event for the request review modal. + * + * Click on the "Yes" button: + * ``` + * { + * "route": "/learn/step/1", + * "action": "click", + * "part": "request_review_modal", + * "target": "yes" + * } + * ``` + * + * Click on the "No" button: + * ``` + * { + * "route": "/learn/step/1", + * "action": "click", + * "part": "request_review_modal", + * "target": "no" + * } + * ``` + * + * Click on the "Write a request" button: + * ``` + * { + * "route": "/learn/step/1", + * "action": "click", + * "part": "request_review_modal", + * "target": "write_a_request" + * } + * ``` + * + * Click on the "Maybe later" button: + * ``` + * { + * "route": "/learn/step/1", + * "action": "click", + * "part": "request_review_modal", + * "target": "maybe_later" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class RequestReviewModalClickHyperskillAnalyticEvent( + route: HyperskillAnalyticRoute, + target: HyperskillAnalyticTarget +) : HyperskillAnalyticEvent( + route, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.REQUEST_REVIEW_MODAL, + target +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/domain/analytic/RequestReviewModalHiddenHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/domain/analytic/RequestReviewModalHiddenHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..c3416cf4ed --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/domain/analytic/RequestReviewModalHiddenHyperskillAnalyticEvent.kt @@ -0,0 +1,30 @@ +package org.hyperskill.app.request_review.modal.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents a hidden analytic event of the request review modal. + * + * JSON payload: + * ``` + * { + * "route": "/learn/step/1", + * "action": "hidden", + * "part": "request_review_modal", + * "target": "close" + * } + * ``` + * @see HyperskillAnalyticEvent + */ +class RequestReviewModalHiddenHyperskillAnalyticEvent( + route: HyperskillAnalyticRoute +) : HyperskillAnalyticEvent( + route, + HyperskillAnalyticAction.HIDDEN, + HyperskillAnalyticPart.REQUEST_REVIEW_MODAL, + HyperskillAnalyticTarget.CLOSE +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/domain/analytic/RequestReviewModalShownHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/domain/analytic/RequestReviewModalShownHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..e9fb4a6744 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/domain/analytic/RequestReviewModalShownHyperskillAnalyticEvent.kt @@ -0,0 +1,31 @@ +package org.hyperskill.app.request_review.modal.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents a shown analytic event of the request review modal. + * + * JSON payload: + * ``` + * { + * "route": "/learn/step/1", + * "action": "shown", + * "part": "modal", + * "target": "request_review_modal" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class RequestReviewModalShownHyperskillAnalyticEvent( + route: HyperskillAnalyticRoute +) : HyperskillAnalyticEvent( + route, + HyperskillAnalyticAction.SHOWN, + HyperskillAnalyticPart.MODAL, + HyperskillAnalyticTarget.REQUEST_REVIEW_MODAL +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/injection/RequestReviewModalComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/injection/RequestReviewModalComponent.kt new file mode 100644 index 0000000000..67d4ffdd2d --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/injection/RequestReviewModalComponent.kt @@ -0,0 +1,10 @@ +package org.hyperskill.app.request_review.modal.injection + +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.Action +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.Message +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.ViewState +import ru.nobird.app.presentation.redux.feature.Feature + +interface RequestReviewModalComponent { + val requestReviewModalFeature: Feature +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/injection/RequestReviewModalComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/injection/RequestReviewModalComponentImpl.kt new file mode 100644 index 0000000000..41e5a0b73e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/injection/RequestReviewModalComponentImpl.kt @@ -0,0 +1,23 @@ +package org.hyperskill.app.request_review.modal.injection + +import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.Action +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.Message +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.ViewState +import org.hyperskill.app.step.domain.model.StepRoute +import ru.nobird.app.presentation.redux.feature.Feature + +internal class RequestReviewModalComponentImpl( + private val appGraph: AppGraph, + private val stepRoute: StepRoute +) : RequestReviewModalComponent { + override val requestReviewModalFeature: Feature + get() = RequestReviewModalFeatureBuilder.build( + stepRoute = stepRoute, + analyticInteractor = appGraph.analyticComponent.analyticInteractor, + logger = appGraph.loggerComponent.logger, + buildVariant = appGraph.commonComponent.buildKonfig.buildVariant, + platform = appGraph.commonComponent.platform, + resourceProvider = appGraph.commonComponent.resourceProvider + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/injection/RequestReviewModalFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/injection/RequestReviewModalFeatureBuilder.kt new file mode 100644 index 0000000000..6c533d74cb --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/injection/RequestReviewModalFeatureBuilder.kt @@ -0,0 +1,52 @@ +package org.hyperskill.app.request_review.modal.injection + +import co.touchlab.kermit.Logger +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.core.domain.BuildVariant +import org.hyperskill.app.core.domain.platform.Platform +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.core.presentation.transformState +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.logging.presentation.wrapWithLogger +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalActionDispatcher +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.Action +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.Message +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.State +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.ViewState +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalReducer +import org.hyperskill.app.request_review.modal.view.mapper.RequestReviewModalViewStateMapper +import org.hyperskill.app.step.domain.model.StepRoute +import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher +import ru.nobird.app.presentation.redux.feature.Feature +import ru.nobird.app.presentation.redux.feature.ReduxFeature + +internal object RequestReviewModalFeatureBuilder { + private const val LOG_TAG = "RequestReviewModalFeature" + + fun build( + stepRoute: StepRoute, + analyticInteractor: AnalyticInteractor, + logger: Logger, + buildVariant: BuildVariant, + platform: Platform, + resourceProvider: ResourceProvider + ): Feature { + val requestReviewModalReducer = RequestReviewModalReducer( + stepRoute = stepRoute, + resourceProvider = resourceProvider + ).wrapWithLogger(buildVariant, logger, LOG_TAG) + val requestReviewModalActionDispatcher = RequestReviewModalActionDispatcher( + ActionDispatcherOptions(), + analyticInteractor = analyticInteractor + ) + + val requestReviewModalViewStateMapper = RequestReviewModalViewStateMapper( + platform = platform, + resourceProvider = resourceProvider + ) + + return ReduxFeature(State.Awaiting, requestReviewModalReducer) + .wrapWithActionDispatcher(requestReviewModalActionDispatcher) + .transformState(requestReviewModalViewStateMapper::map) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/presentation/RequestReviewModalActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/presentation/RequestReviewModalActionDispatcher.kt new file mode 100644 index 0000000000..6ae4082409 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/presentation/RequestReviewModalActionDispatcher.kt @@ -0,0 +1,23 @@ +package org.hyperskill.app.request_review.modal.presentation + +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.Action +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.InternalAction +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.Message +import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher + +internal class RequestReviewModalActionDispatcher( + config: ActionDispatcherOptions, + private val analyticInteractor: AnalyticInteractor +) : CoroutineActionDispatcher(config.createConfig()) { + override suspend fun doSuspendableAction(action: Action) { + when (action) { + is InternalAction.LogAnalyticEvent -> + analyticInteractor.logEvent(action.analyticEvent) + else -> { + // no op + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/presentation/RequestReviewModalFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/presentation/RequestReviewModalFeature.kt new file mode 100644 index 0000000000..2c3a902cc0 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/presentation/RequestReviewModalFeature.kt @@ -0,0 +1,52 @@ +package org.hyperskill.app.request_review.modal.presentation + +import org.hyperskill.app.analytic.domain.model.AnalyticEvent + +object RequestReviewModalFeature { + internal sealed interface State { + object Awaiting : State + object Negative : State + object Positive : State + } + + data class ViewState( + val title: String, + val description: String?, + val positiveButtonText: String, + val negativeButtonText: String, + val state: State + ) { + enum class State { + AWAITING, + NEGATIVE, + POSITIVE + } + } + + sealed interface Message { + object PositiveButtonClicked : Message + object NegativeButtonClicked : Message + + /** + * Analytic + */ + object ShownEventMessage : Message + object HiddenEventMessage : Message + } + + internal sealed interface InternalMessage : Message + + sealed interface Action { + sealed interface ViewAction : Action { + object RequestUserReview : ViewAction + + data class SubmitSupportRequest(val url: String) : ViewAction + + object Dismiss : ViewAction + } + } + + internal sealed interface InternalAction : Action { + data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/presentation/RequestReviewModalReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/presentation/RequestReviewModalReducer.kt new file mode 100644 index 0000000000..ad58d7a7cc --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/presentation/RequestReviewModalReducer.kt @@ -0,0 +1,97 @@ +package org.hyperskill.app.request_review.modal.presentation + +import org.hyperskill.app.SharedResources +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.request_review.modal.domain.analytic.RequestReviewModalClickHyperskillAnalyticEvent +import org.hyperskill.app.request_review.modal.domain.analytic.RequestReviewModalHiddenHyperskillAnalyticEvent +import org.hyperskill.app.request_review.modal.domain.analytic.RequestReviewModalShownHyperskillAnalyticEvent +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.Action +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.InternalAction +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.Message +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.State +import org.hyperskill.app.step.domain.model.StepRoute +import ru.nobird.app.presentation.redux.reducer.StateReducer + +private typealias ReducerResult = Pair> + +internal class RequestReviewModalReducer( + private val stepRoute: StepRoute, + private val resourceProvider: ResourceProvider +) : StateReducer { + override fun reduce(state: State, message: Message): ReducerResult = + when (message) { + Message.PositiveButtonClicked -> + handlePositiveButtonClickedMessage(state) + Message.NegativeButtonClicked -> + handleNegativeButtonClickedMessage(state) + // Analytic + Message.ShownEventMessage -> + state to setOf( + InternalAction.LogAnalyticEvent( + RequestReviewModalShownHyperskillAnalyticEvent(stepRoute.analyticRoute) + ) + ) + Message.HiddenEventMessage -> + state to setOf( + InternalAction.LogAnalyticEvent( + RequestReviewModalHiddenHyperskillAnalyticEvent(stepRoute.analyticRoute) + ) + ) + } ?: (state to emptySet()) + + private fun handlePositiveButtonClickedMessage( + state: State + ): ReducerResult? = + when (state) { + State.Awaiting -> + State.Positive to setOf( + InternalAction.LogAnalyticEvent( + RequestReviewModalClickHyperskillAnalyticEvent( + route = stepRoute.analyticRoute, + target = HyperskillAnalyticTarget.YES + ) + ), + Action.ViewAction.RequestUserReview + ) + State.Negative -> + state to setOf( + InternalAction.LogAnalyticEvent( + RequestReviewModalClickHyperskillAnalyticEvent( + route = stepRoute.analyticRoute, + target = HyperskillAnalyticTarget.WRITE_A_REQUEST + ) + ), + Action.ViewAction.SubmitSupportRequest( + resourceProvider.getString(SharedResources.strings.settings_report_problem_url) + ) + ) + State.Positive -> null + } + + private fun handleNegativeButtonClickedMessage( + state: State + ): ReducerResult? = + when (state) { + State.Awaiting -> + State.Negative to setOf( + InternalAction.LogAnalyticEvent( + RequestReviewModalClickHyperskillAnalyticEvent( + route = stepRoute.analyticRoute, + target = HyperskillAnalyticTarget.NO + ) + ) + ) + State.Negative -> + state to setOf( + InternalAction.LogAnalyticEvent( + RequestReviewModalClickHyperskillAnalyticEvent( + route = stepRoute.analyticRoute, + target = HyperskillAnalyticTarget.MAYBE_LATER + ) + ), + Action.ViewAction.Dismiss + ) + State.Positive -> null + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/view/mapper/RequestReviewModalViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/view/mapper/RequestReviewModalViewStateMapper.kt new file mode 100644 index 0000000000..ffd2678fbf --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/request_review/modal/view/mapper/RequestReviewModalViewStateMapper.kt @@ -0,0 +1,51 @@ +package org.hyperskill.app.request_review.modal.view.mapper + +import org.hyperskill.app.SharedResources +import org.hyperskill.app.core.domain.platform.Platform +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.State +import org.hyperskill.app.request_review.modal.presentation.RequestReviewModalFeature.ViewState + +internal class RequestReviewModalViewStateMapper( + private val platform: Platform, + private val resourceProvider: ResourceProvider +) { + fun map(state: State): ViewState = + when (state) { + State.Awaiting, State.Positive -> + ViewState( + title = resourceProvider.getString( + SharedResources.strings.request_review_modal_state_awaiting_title, + resourceProvider.getString(platform.appNameResource) + ), + description = null, + positiveButtonText = resourceProvider.getString( + SharedResources.strings.request_review_modal_state_awaiting_positive_button_text + ), + negativeButtonText = resourceProvider.getString( + SharedResources.strings.request_review_modal_state_awaiting_negative_button_text + ), + state = when (state) { + State.Awaiting -> ViewState.State.AWAITING + State.Positive -> ViewState.State.POSITIVE + State.Negative -> throw IllegalStateException("State.Negative shouldn't be mapped to ViewState") + } + ) + State.Negative -> + ViewState( + title = resourceProvider.getString( + SharedResources.strings.request_review_modal_state_negative_title + ), + description = resourceProvider.getString( + SharedResources.strings.request_review_modal_state_negative_description + ), + positiveButtonText = resourceProvider.getString( + SharedResources.strings.request_review_modal_state_negative_positive_button_text + ), + negativeButtonText = resourceProvider.getString( + SharedResources.strings.request_review_modal_state_negative_negative_button_text + ), + state = ViewState.State.NEGATIVE + ) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt index f599b85a04..047794f8e0 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt @@ -327,4 +327,40 @@ object HyperskillSentryTransactionBuilder { name = "streak-recovery-feature-fetch-streak", operation = HyperskillSentryTransactionOperation.API_LOAD ) + + /** + * ProfileSettingsFeature + */ + fun buildProfileSettingsFeatureFetchSubscription(): HyperskillSentryTransaction = + HyperskillSentryTransaction( + name = "profile-settings-feature-fetch-subscription", + operation = HyperskillSentryTransactionOperation.API_LOAD + ) + + fun buildManageSubscriptionFeatureFetchSubscription(): HyperskillSentryTransaction = + HyperskillSentryTransaction( + name = "manage-subscription-feature-fetch-subscription", + operation = HyperskillSentryTransactionOperation.API_LOAD + ) + + /** + * PaywallFeature + */ + fun buildPaywallFeatureSyncSubscription(): HyperskillSentryTransaction = + HyperskillSentryTransaction( + name = "paywall-feature-sync-subscription", + operation = HyperskillSentryTransactionOperation.API_LOAD + ) + + fun buildPaywallFeaturePurchaseSubscription(): HyperskillSentryTransaction = + HyperskillSentryTransaction( + name = "paywall-feature-purchase-subscription", + operation = HyperskillSentryTransactionOperation.API_LOAD + ) + + fun buildPaywallFetchSubscriptionPrice(): HyperskillSentryTransaction = + HyperskillSentryTransaction( + name = "paywall-feature-fetch-subscription-price", + operation = HyperskillSentryTransactionOperation.API_LOAD + ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/injection/StageImplementComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/injection/StageImplementComponent.kt index 019059d704..0f7ade8dd9 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/injection/StageImplementComponent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/injection/StageImplementComponent.kt @@ -1,9 +1,10 @@ package org.hyperskill.app.stage_implement.injection -import org.hyperskill.app.stage_implement.presentation.StageImplementFeature +import org.hyperskill.app.stage_implement.presentation.StageImplementFeature.Action +import org.hyperskill.app.stage_implement.presentation.StageImplementFeature.Message +import org.hyperskill.app.stage_implement.presentation.StageImplementFeature.ViewState import ru.nobird.app.presentation.redux.feature.Feature interface StageImplementComponent { - val stageImplementFeature: Feature< - StageImplementFeature.ViewState, StageImplementFeature.Message, StageImplementFeature.Action> + val stageImplementFeature: Feature } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/injection/StageImplementComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/injection/StageImplementComponentImpl.kt index 7dbc796495..ad8f4ff4a2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/injection/StageImplementComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/injection/StageImplementComponentImpl.kt @@ -1,16 +1,17 @@ package org.hyperskill.app.stage_implement.injection import org.hyperskill.app.core.injection.AppGraph -import org.hyperskill.app.stage_implement.presentation.StageImplementFeature +import org.hyperskill.app.stage_implement.presentation.StageImplementFeature.Action +import org.hyperskill.app.stage_implement.presentation.StageImplementFeature.Message +import org.hyperskill.app.stage_implement.presentation.StageImplementFeature.ViewState import ru.nobird.app.presentation.redux.feature.Feature -class StageImplementComponentImpl( +internal class StageImplementComponentImpl( private val appGraph: AppGraph, private val projectId: Long, private val stageId: Long ) : StageImplementComponent { - override val stageImplementFeature: Feature< - StageImplementFeature.ViewState, StageImplementFeature.Message, StageImplementFeature.Action> + override val stageImplementFeature: Feature get() = StageImplementFeatureBuilder.build( projectId, stageId, @@ -20,7 +21,7 @@ class StageImplementComponentImpl( appGraph.sentryComponent.sentryInteractor, appGraph.commonComponent.resourceProvider, appGraph.profileDataComponent.currentProfileStateRepository, - appGraph.submissionDataComponent.submissionRepository, + appGraph.stepCompletionFlowDataComponent.stepCompletedFlow, appGraph.loggerComponent.logger, appGraph.commonComponent.buildKonfig.buildVariant ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/injection/StageImplementFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/injection/StageImplementFeatureBuilder.kt index eb1f6f7960..e12beb5be7 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/injection/StageImplementFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/injection/StageImplementFeatureBuilder.kt @@ -13,10 +13,13 @@ import org.hyperskill.app.progresses.domain.interactor.ProgressesInteractor import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.stage_implement.presentation.StageImplementActionDispatcher import org.hyperskill.app.stage_implement.presentation.StageImplementFeature +import org.hyperskill.app.stage_implement.presentation.StageImplementFeature.Action +import org.hyperskill.app.stage_implement.presentation.StageImplementFeature.Message +import org.hyperskill.app.stage_implement.presentation.StageImplementFeature.ViewState import org.hyperskill.app.stage_implement.presentation.StageImplementReducer import org.hyperskill.app.stage_implement.view.mapper.StageImplementViewStateMapper import org.hyperskill.app.stages.domain.interactor.StagesInteractor -import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher import ru.nobird.app.presentation.redux.feature.Feature import ru.nobird.app.presentation.redux.feature.ReduxFeature @@ -33,16 +36,20 @@ internal object StageImplementFeatureBuilder { sentryInteractor: SentryInteractor, resourceProvider: ResourceProvider, currentProfileStateRepository: CurrentProfileStateRepository, - submissionRepository: SubmissionRepository, + stepCompletedFlow: StepCompletedFlow, logger: Logger, buildVariant: BuildVariant - ): Feature { - val analyticRoute = HyperskillAnalyticRoute.Projects.Stages.Implement(projectId = projectId, stageId = stageId) - val stageImplementReducer = StageImplementReducer(analyticRoute).wrapWithLogger(buildVariant, logger, LOG_TAG) + ): Feature { + val analyticRoute = HyperskillAnalyticRoute.Projects.Stages.Implement( + projectId = projectId, + stageId = stageId + ) + val stageImplementReducer = StageImplementReducer(analyticRoute) + .wrapWithLogger(buildVariant, logger, LOG_TAG) val stageImplementActionDispatcher = StageImplementActionDispatcher( ActionDispatcherOptions(), - submissionRepository, + stepCompletedFlow, currentProfileStateRepository, stagesInteractor, progressesInteractor, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/presentation/StageImplementActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/presentation/StageImplementActionDispatcher.kt index 18933b2abc..f983816952 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/presentation/StageImplementActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/stage_implement/presentation/StageImplementActionDispatcher.kt @@ -18,12 +18,12 @@ import org.hyperskill.app.stage_implement.presentation.StageImplementFeature.Int import org.hyperskill.app.stage_implement.presentation.StageImplementFeature.InternalMessage import org.hyperskill.app.stage_implement.presentation.StageImplementFeature.Message import org.hyperskill.app.stages.domain.interactor.StagesInteractor -import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher internal class StageImplementActionDispatcher( config: ActionDispatcherOptions, - submissionRepository: SubmissionRepository, + stepCompletedFlow: StepCompletedFlow, private val currentProfileStateRepository: CurrentProfileStateRepository, private val stagesInteractor: StagesInteractor, private val progressesInteractor: ProgressesInteractor, @@ -32,7 +32,7 @@ internal class StageImplementActionDispatcher( private val resourceProvider: ResourceProvider ) : CoroutineActionDispatcher(config.createConfig()) { init { - submissionRepository.solvedStepsMutableSharedFlow + stepCompletedFlow.observe() .onEach { onNewMessage(InternalMessage.StepSolved(it)) } .launchIn(actionScope) } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt index dbe127f9b5..21c2c576e6 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/BlockName.kt @@ -15,7 +15,6 @@ object BlockName { const val TEXT = "text" const val PARSONS = "parsons" const val FILL_BLANKS = "fill-blanks" - const val VIDEO = "video" val codeRelatedBlocksNames: Set = setOf(CODE, PYCHARM, SQL) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepComponentImpl.kt index cccebdc016..a9d98fd605 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepComponentImpl.kt @@ -7,7 +7,10 @@ import org.hyperskill.app.step.view.mapper.CommentThreadTitleMapper import org.hyperskill.app.step_completion.injection.StepCompletionComponent import ru.nobird.app.presentation.redux.feature.Feature -class StepComponentImpl(private val appGraph: AppGraph, private val stepRoute: StepRoute) : StepComponent { +internal class StepComponentImpl( + private val appGraph: AppGraph, + private val stepRoute: StepRoute +) : StepComponent { override val commentThreadTitleMapper: CommentThreadTitleMapper get() = CommentThreadTitleMapper(appGraph.commonComponent.resourceProvider) @@ -19,6 +22,7 @@ class StepComponentImpl(private val appGraph: AppGraph, private val stepRoute: S stepRoute, appGraph.buildStepDataComponent().stepInteractor, appGraph.stateRepositoriesComponent.nextLearningActivityStateRepository, + appGraph.profileDataComponent.currentProfileStateRepository, appGraph.analyticComponent.analyticInteractor, appGraph.sentryComponent.sentryInteractor, stepCompletionComponent.stepCompletionReducer, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepDataComponentImpl.kt index 5c87c08be7..9cbfa8e90f 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepDataComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepDataComponentImpl.kt @@ -7,7 +7,7 @@ import org.hyperskill.app.step.domain.interactor.StepInteractor import org.hyperskill.app.step.domain.repository.StepRepository import org.hyperskill.app.step.remote.StepRemoteDataSourceImpl -class StepDataComponentImpl(appGraph: AppGraph) : StepDataComponent { +internal class StepDataComponentImpl(appGraph: AppGraph) : StepDataComponent { private val stepRemoteDataSource: StepRemoteDataSource = StepRemoteDataSourceImpl(appGraph.networkComponent.authorizedHttpClient) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepFeatureBuilder.kt index 5467686544..ba9aa668e9 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/injection/StepFeatureBuilder.kt @@ -6,11 +6,13 @@ import org.hyperskill.app.core.domain.BuildVariant import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.learning_activities.domain.repository.NextLearningActivityStateRepository import org.hyperskill.app.logging.presentation.wrapWithLogger +import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.step.domain.interactor.StepInteractor import org.hyperskill.app.step.domain.model.StepRoute import org.hyperskill.app.step.presentation.StepActionDispatcher import org.hyperskill.app.step.presentation.StepFeature.Action +import org.hyperskill.app.step.presentation.StepFeature.InternalAction import org.hyperskill.app.step.presentation.StepFeature.Message import org.hyperskill.app.step.presentation.StepFeature.State import org.hyperskill.app.step.presentation.StepReducer @@ -22,13 +24,14 @@ import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher import ru.nobird.app.presentation.redux.feature.Feature import ru.nobird.app.presentation.redux.feature.ReduxFeature -object StepFeatureBuilder { +internal object StepFeatureBuilder { private const val LOG_TAG = "StepFeature" fun build( stepRoute: StepRoute, stepInteractor: StepInteractor, nextLearningActivityStateRepository: NextLearningActivityStateRepository, + currentProfileStateRepository: CurrentProfileStateRepository, analyticInteractor: AnalyticInteractor, sentryInteractor: SentryInteractor, stepCompletionReducer: StepCompletionReducer, @@ -38,18 +41,19 @@ object StepFeatureBuilder { ): Feature { val stepReducer = StepReducer(stepRoute, stepCompletionReducer).wrapWithLogger(buildVariant, logger, LOG_TAG) val stepActionDispatcher = StepActionDispatcher( - ActionDispatcherOptions(), - stepInteractor, - nextLearningActivityStateRepository, - analyticInteractor, - sentryInteractor + config = ActionDispatcherOptions(), + stepInteractor = stepInteractor, + nextLearningActivityStateRepository = nextLearningActivityStateRepository, + currentProfileStateRepository = currentProfileStateRepository, + analyticInteractor = analyticInteractor, + sentryInteractor = sentryInteractor ) return ReduxFeature(State.Idle, stepReducer) .wrapWithActionDispatcher(stepActionDispatcher) .wrapWithActionDispatcher( stepCompletionActionDispatcher.transform( - transformAction = { it.safeCast()?.action }, + transformAction = { it.safeCast()?.action }, transformMessage = Message::StepCompletionMessage ) ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepActionDispatcher.kt index 92ec4449e9..175d58b8be 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepActionDispatcher.kt @@ -4,52 +4,96 @@ import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.core.domain.DataSourceType import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.learning_activities.domain.repository.NextLearningActivityStateRepository +import org.hyperskill.app.profile.domain.model.isMobileShortTheoryEnabled +import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder +import org.hyperskill.app.sentry.domain.withTransaction import org.hyperskill.app.step.domain.interactor.StepInteractor +import org.hyperskill.app.step.domain.model.Step import org.hyperskill.app.step.presentation.StepFeature.Action +import org.hyperskill.app.step.presentation.StepFeature.InternalAction import org.hyperskill.app.step.presentation.StepFeature.Message import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher -class StepActionDispatcher( +internal class StepActionDispatcher( config: ActionDispatcherOptions, private val stepInteractor: StepInteractor, private val nextLearningActivityStateRepository: NextLearningActivityStateRepository, + private val currentProfileStateRepository: CurrentProfileStateRepository, private val analyticInteractor: AnalyticInteractor, private val sentryInteractor: SentryInteractor ) : CoroutineActionDispatcher(config.createConfig()) { override suspend fun doSuspendableAction(action: Action) { when (action) { - is Action.FetchStep -> { - val sentryTransaction = HyperskillSentryTransactionBuilder.buildStepScreenRemoteDataLoading() - sentryInteractor.startTransaction(sentryTransaction) - - stepInteractor - .getStep(action.stepRoute.stepId) - .fold( - onSuccess = { - sentryInteractor.finishTransaction(sentryTransaction) - onNewMessage(Message.StepLoaded.Success(it)) - }, - onFailure = { - sentryInteractor.finishTransaction(sentryTransaction, throwable = it) - onNewMessage(Message.StepLoaded.Error) - } - ) - } - is Action.ViewStep -> { + is InternalAction.FetchStep -> + handleFetchStepAction(action, ::onNewMessage) + is InternalAction.ViewStep -> stepInteractor.viewStep(action.stepId, action.stepContext) - } - is Action.UpdateNextLearningActivityState -> { + is InternalAction.UpdateNextLearningActivityState -> handleUpdateNextLearningActivityStateAction(action) - } - is Action.LogAnalyticEvent -> + is InternalAction.LogAnalyticEvent -> analyticInteractor.logEvent(action.analyticEvent) - else -> {} + else -> { + // no op + } + } + } + + private suspend fun handleFetchStepAction( + action: InternalAction.FetchStep, + onNewMessage: (Message) -> Unit + ) { + sentryInteractor.withTransaction( + HyperskillSentryTransactionBuilder.buildStepScreenRemoteDataLoading(), + onError = { Message.StepLoaded.Error } + ) { + val step = stepInteractor + .getStep(action.stepRoute.stepId) + .getOrThrow() + .let { applyMobileShortTheoryFeature(it) } + Message.StepLoaded.Success(step) + }.let(onNewMessage) + } + + private suspend fun applyMobileShortTheoryFeature(step: Step): Step { + val isMobileShortTheoryEnabled = currentProfileStateRepository + .getState(forceUpdate = false) + .getOrNull() + ?.features + ?.isMobileShortTheoryEnabled + ?: false + + return if (isMobileShortTheoryEnabled && step.id == 38627L) { + step.copy( + block = step.block.copy( + text = """ +

Ever wondered why Java's logo is a steaming cup of coffee? Just as coffee fuels our day, Java powers the tech world with its robust and versatile features! So, grab your cup of coffee and join us on this exciting journey into the world of Java!

+
What is Java
+

Java language was designed by James Gosling in 1995 to be simple and powerful. It borrows syntax from C and C++, and complements it with automatic memory management, and other powerful features. Java's core principle is "Write Once, Run Anywhere" (WORA), which means that any Java program is platform-independent and can run on any operating system without modifications.

+
Where is Java Applied
+

Waking up you immediately interact with an application built with Java โ€” your phone alarm. When you work or develop your pet projects, Java forms the backbone of development tools like IntelliJ IDEA. Even when relaxing with Netflix, Spotify, or Minecraft, you rely on Java power. Java is like a silent friend, aiding us and making our lives easier in numerous ways, from the moment we wake up till we call it a day. What amazing and helpful Java application are you going to create?

+
A sample of Java
+

Let's start with the classic "Hello, World!" program, a friendly greeting from your computer:

+
public class HelloWorld {
+                public static void main(String[] args) {
+                    System.out.println("Hello, World!");
+                }
+            }
+

This program simply prints the phrase "Hello, World!" to the console. Don't worry if it looks a bit cryptic now. We'll dive deeper into its logic during the practice part of this topic.

+
Conclusion
+

Java is a high-level object-oriented programming language. Its clear syntax, platform independence, and automatic memory management contribute to its popularity. Become a part of the vast Java community by practicing the language basics now. Don't hesitate to experiment, ask for help if you're stuck, and embrace mistakes as they fuel your learning!

+ """.trimIndent() + ) + ) + } else { + step } } - private suspend fun handleUpdateNextLearningActivityStateAction(action: Action.UpdateNextLearningActivityState) { + private suspend fun handleUpdateNextLearningActivityStateAction( + action: InternalAction.UpdateNextLearningActivityState + ) { val currentNextLearningActivityState = nextLearningActivityStateRepository .getStateWithSource(forceUpdate = false) .getOrElse { return } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepFeature.kt index 1f44407482..bc58de171a 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepFeature.kt @@ -6,7 +6,7 @@ import org.hyperskill.app.step.domain.model.StepContext import org.hyperskill.app.step.domain.model.StepRoute import org.hyperskill.app.step_completion.presentation.StepCompletionFeature -interface StepFeature { +object StepFeature { sealed interface State { object Idle : State object Loading : State @@ -38,21 +38,24 @@ interface StepFeature { } sealed interface Action { - data class FetchStep(val stepRoute: StepRoute) : Action - data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : Action - data class ViewStep(val stepId: Long, val stepContext: StepContext) : Action - - data class UpdateNextLearningActivityState(val step: Step) : Action - - /** - * Action Wrappers - */ - data class StepCompletionAction(val action: StepCompletionFeature.Action) : Action - sealed interface ViewAction : Action { data class StepCompletionViewAction( val viewAction: StepCompletionFeature.Action.ViewAction ) : ViewAction } } + + internal sealed interface InternalAction : Action { + data class FetchStep(val stepRoute: StepRoute) : InternalAction + data class ViewStep(val stepId: Long, val stepContext: StepContext) : InternalAction + + data class UpdateNextLearningActivityState(val step: Step) : InternalAction + + data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction + + /** + * Action Wrappers + */ + data class StepCompletionAction(val action: StepCompletionFeature.Action) : InternalAction + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepReducer.kt index 3751a5b7bf..7afb6ccdae 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepReducer.kt @@ -3,13 +3,14 @@ package org.hyperskill.app.step.presentation import org.hyperskill.app.step.domain.analytic.StepViewedHyperskillAnalyticEvent import org.hyperskill.app.step.domain.model.StepRoute import org.hyperskill.app.step.presentation.StepFeature.Action +import org.hyperskill.app.step.presentation.StepFeature.InternalAction import org.hyperskill.app.step.presentation.StepFeature.Message import org.hyperskill.app.step.presentation.StepFeature.State import org.hyperskill.app.step_completion.presentation.StepCompletionFeature import org.hyperskill.app.step_completion.presentation.StepCompletionReducer import ru.nobird.app.presentation.redux.reducer.StateReducer -class StepReducer( +internal class StepReducer( private val stepRoute: StepRoute, private val stepCompletionReducer: StepCompletionReducer ) : StateReducer { @@ -20,8 +21,8 @@ class StepReducer( (message.forceUpdate && (state is State.Data || state is State.Error)) ) { State.Loading to setOf( - Action.FetchStep(stepRoute), - Action.ViewStep(stepRoute.stepId, stepRoute.stepContext) + InternalAction.FetchStep(stepRoute), + InternalAction.ViewStep(stepRoute.stepId, stepRoute.stepContext) ) } else { null @@ -42,7 +43,7 @@ class StepReducer( false }, stepCompletionState = StepCompletionFeature.createState(message.step, stepRoute) - ) to setOf(Action.UpdateNextLearningActivityState(message.step)) + ) to setOf(InternalAction.UpdateNextLearningActivityState(message.step)) } is Message.StepLoaded.Error -> State.Error to emptySet() @@ -52,7 +53,7 @@ class StepReducer( null } else { state to setOf( - Action.LogAnalyticEvent( + InternalAction.LogAnalyticEvent( StepViewedHyperskillAnalyticEvent( stepRoute.analyticRoute ) @@ -80,7 +81,7 @@ class StepReducer( if (it is StepCompletionFeature.Action.ViewAction) { Action.ViewAction.StepCompletionViewAction(it) } else { - Action.StepCompletionAction(it) + InternalAction.StepCompletionAction(it) } } .toSet() diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/data/flow/StepCompletedFlowImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/data/flow/StepCompletedFlowImpl.kt new file mode 100644 index 0000000000..e6098791e1 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/data/flow/StepCompletedFlowImpl.kt @@ -0,0 +1,16 @@ +package org.hyperskill.app.step_completion.data.flow + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow + +internal class StepCompletedFlowImpl : StepCompletedFlow { + private val stepSolvedMutableSharedFlow = MutableSharedFlow() + + override fun observe(): Flow = + stepSolvedMutableSharedFlow + + override suspend fun notifyDataChanged(data: Long) { + stepSolvedMutableSharedFlow.emit(data) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/domain/flow/StepCompletedFlow.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/domain/flow/StepCompletedFlow.kt new file mode 100644 index 0000000000..7549344d95 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/domain/flow/StepCompletedFlow.kt @@ -0,0 +1,5 @@ +package org.hyperskill.app.step_completion.domain.flow + +import org.hyperskill.app.core.domain.flow.SharedDataFlow + +interface StepCompletedFlow : SharedDataFlow \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionComponentImpl.kt index d85b166be3..10b01c018e 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionComponentImpl.kt @@ -16,21 +16,24 @@ internal class StepCompletionComponentImpl( override val stepCompletionActionDispatcher: StepCompletionActionDispatcher get() = StepCompletionActionDispatcher( ActionDispatcherOptions(), - appGraph.submissionDataComponent.submissionRepository, - appGraph.buildStepDataComponent().stepInteractor, - appGraph.buildProgressesDataComponent().progressesInteractor, - appGraph.buildTopicsDataComponent().topicsRepository, - appGraph.analyticComponent.analyticInteractor, - appGraph.commonComponent.resourceProvider, - appGraph.sentryComponent.sentryInteractor, - appGraph.buildFreemiumDataComponent().freemiumInteractor, - appGraph.buildShareStreakDataComponent().shareStreakInteractor, - appGraph.stateRepositoriesComponent.nextLearningActivityStateRepository, - appGraph.profileDataComponent.currentProfileStateRepository, - appGraph.stateRepositoriesComponent.currentGamificationToolbarDataStateRepository, - appGraph.stepCompletionFlowDataComponent.dailyStepCompletedFlow, - appGraph.stepCompletionFlowDataComponent.topicCompletedFlow, - appGraph.progressesFlowDataComponent.topicProgressFlow, - appGraph.stateRepositoriesComponent.interviewStepsStateRepository + stepCompletedFlow = appGraph.stepCompletionFlowDataComponent.stepCompletedFlow, + stepInteractor = appGraph.buildStepDataComponent().stepInteractor, + progressesInteractor = appGraph.buildProgressesDataComponent().progressesInteractor, + topicsRepository = appGraph.buildTopicsDataComponent().topicsRepository, + analyticInteractor = appGraph.analyticComponent.analyticInteractor, + resourceProvider = appGraph.commonComponent.resourceProvider, + sentryInteractor = appGraph.sentryComponent.sentryInteractor, + subscriptionsInteractor = appGraph.subscriptionDataComponent.subscriptionsInteractor, + shareStreakInteractor = appGraph.buildShareStreakDataComponent().shareStreakInteractor, + requestReviewInteractor = appGraph.buildRequestReviewDataComponent().requestReviewInteractor, + nextLearningActivityStateRepository = appGraph.stateRepositoriesComponent + .nextLearningActivityStateRepository, + currentProfileStateRepository = appGraph.profileDataComponent.currentProfileStateRepository, + currentGamificationToolbarDataStateRepository = appGraph.stateRepositoriesComponent + .currentGamificationToolbarDataStateRepository, + dailyStepCompletedFlow = appGraph.stepCompletionFlowDataComponent.dailyStepCompletedFlow, + topicCompletedFlow = appGraph.stepCompletionFlowDataComponent.topicCompletedFlow, + topicProgressFlow = appGraph.progressesFlowDataComponent.topicProgressFlow, + interviewStepsStateRepository = appGraph.stateRepositoriesComponent.interviewStepsStateRepository ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponent.kt index 1cf8a7293c..270791bae8 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponent.kt @@ -1,9 +1,11 @@ package org.hyperskill.app.step_completion.injection import org.hyperskill.app.step_completion.domain.flow.DailyStepCompletedFlow +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow import org.hyperskill.app.step_completion.domain.flow.TopicCompletedFlow interface StepCompletionFlowDataComponent { val topicCompletedFlow: TopicCompletedFlow val dailyStepCompletedFlow: DailyStepCompletedFlow + val stepCompletedFlow: StepCompletedFlow } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponentImpl.kt index f1d0b563f2..723d2575e3 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionFlowDataComponentImpl.kt @@ -1,8 +1,10 @@ package org.hyperskill.app.step_completion.injection import org.hyperskill.app.step_completion.data.flow.DailyStepCompletedFlowImpl +import org.hyperskill.app.step_completion.data.flow.StepCompletedFlowImpl import org.hyperskill.app.step_completion.data.flow.TopicCompletedFlowImpl import org.hyperskill.app.step_completion.domain.flow.DailyStepCompletedFlow +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow import org.hyperskill.app.step_completion.domain.flow.TopicCompletedFlow internal class StepCompletionFlowDataComponentImpl : StepCompletionFlowDataComponent { @@ -11,4 +13,7 @@ internal class StepCompletionFlowDataComponentImpl : StepCompletionFlowDataCompo override val dailyStepCompletedFlow: DailyStepCompletedFlow = DailyStepCompletedFlowImpl() + + override val stepCompletedFlow: StepCompletedFlow = + StepCompletedFlowImpl() } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionActionDispatcher.kt index 956fd5150c..dd9078efff 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionActionDispatcher.kt @@ -9,13 +9,13 @@ import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.core.domain.repository.updateState import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.freemium.domain.interactor.FreemiumInteractor import org.hyperskill.app.gamification_toolbar.domain.repository.CurrentGamificationToolbarDataStateRepository import org.hyperskill.app.interview_steps.domain.repository.InterviewStepsStateRepository import org.hyperskill.app.learning_activities.domain.repository.NextLearningActivityStateRepository import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.progresses.domain.flow.TopicProgressFlow import org.hyperskill.app.progresses.domain.interactor.ProgressesInteractor +import org.hyperskill.app.request_review.domain.interactor.RequestReviewInteractor import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder import org.hyperskill.app.sentry.domain.withTransaction @@ -26,28 +26,30 @@ import org.hyperskill.app.step.domain.model.StepRoute import org.hyperskill.app.step_completion.domain.analytic.StepCompletionStepSolvedAppsFlyerAnalyticEvent import org.hyperskill.app.step_completion.domain.analytic.StepCompletionTopicCompletedAppsFlyerAnalyticEvent import org.hyperskill.app.step_completion.domain.flow.DailyStepCompletedFlow +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow import org.hyperskill.app.step_completion.domain.flow.TopicCompletedFlow import org.hyperskill.app.step_completion.presentation.StepCompletionFeature.Action import org.hyperskill.app.step_completion.presentation.StepCompletionFeature.InternalAction import org.hyperskill.app.step_completion.presentation.StepCompletionFeature.InternalMessage import org.hyperskill.app.step_completion.presentation.StepCompletionFeature.Message -import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository import org.hyperskill.app.streaks.domain.model.StreakState +import org.hyperskill.app.subscriptions.domain.interactor.SubscriptionsInteractor import org.hyperskill.app.topics.domain.repository.TopicsRepository import ru.nobird.app.core.model.mutate import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher class StepCompletionActionDispatcher( config: ActionDispatcherOptions, - submissionRepository: SubmissionRepository, + stepCompletedFlow: StepCompletedFlow, private val stepInteractor: StepInteractor, private val progressesInteractor: ProgressesInteractor, private val topicsRepository: TopicsRepository, private val analyticInteractor: AnalyticInteractor, private val resourceProvider: ResourceProvider, private val sentryInteractor: SentryInteractor, - private val freemiumInteractor: FreemiumInteractor, + private val subscriptionsInteractor: SubscriptionsInteractor, private val shareStreakInteractor: ShareStreakInteractor, + private val requestReviewInteractor: RequestReviewInteractor, private val nextLearningActivityStateRepository: NextLearningActivityStateRepository, private val currentProfileStateRepository: CurrentProfileStateRepository, private val currentGamificationToolbarDataStateRepository: CurrentGamificationToolbarDataStateRepository, @@ -58,7 +60,7 @@ class StepCompletionActionDispatcher( ) : CoroutineActionDispatcher(config.createConfig()) { init { - submissionRepository.solvedStepsSharedFlow + stepCompletedFlow.observe() .onEach(::handleStepSolved) .launchIn(actionScope) } @@ -99,7 +101,7 @@ class StepCompletionActionDispatcher( handleCheckTopicCompletionStatusAction(action, ::onNewMessage) } is Action.UpdateProblemsLimit -> { - freemiumInteractor.onStepSolved() + subscriptionsInteractor.chargeProblemsLimits(action.chargeStrategy) } is Action.UpdateLastTimeShareStreakShown -> { shareStreakInteractor.setLastTimeShareStreakShown() @@ -234,11 +236,15 @@ class StepCompletionActionDispatcher( val currentProfileHypercoinsBalance = updateCurrentProfileHypercoinsBalanceRemotely() if (currentProfileHypercoinsBalance != null) { val gemsEarned = currentProfileHypercoinsBalance - cachedProfile.gamification.hypercoinsBalance - val earnedGemsText = resourceProvider.getQuantityString( - SharedResources.plurals.earned_gems, - gemsEarned, - gemsEarned - ) + val earnedGemsText = if (gemsEarned > 0) { + resourceProvider.getQuantityString( + SharedResources.plurals.earned_gems, + gemsEarned, + gemsEarned + ) + } else { + null + } val shareStreakData = if (shouldShareStreak && streakToShare != null) { val daysText = resourceProvider.getQuantityString( @@ -267,12 +273,17 @@ class StepCompletionActionDispatcher( } } + val shouldRequestReview = requestReviewInteractor.shouldRequestReviewAfterStepSolved() + if (shouldShareStreak && streakToShare != null) { shareStreakInteractor.setLastTimeShareStreakShown() onNewMessage(Message.ShareStreak(streak = streakToShare)) - - updateCurrentProfileHypercoinsBalanceRemotely() + } else if (shouldRequestReview) { + requestReviewInteractor.handleRequestReviewPerformed() + onNewMessage(Message.RequestUserReview) } + + updateCurrentProfileHypercoinsBalanceRemotely() } private suspend fun updateCurrentProfileHypercoinsBalanceRemotely(): Int? = diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionFeature.kt index d45cbc99f8..7a9f52dd51 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionFeature.kt @@ -5,6 +5,7 @@ import org.hyperskill.app.analytic.domain.model.AnalyticEvent import org.hyperskill.app.learning_activities.domain.model.LearningActivity import org.hyperskill.app.step.domain.model.Step import org.hyperskill.app.step.domain.model.StepRoute +import org.hyperskill.app.subscriptions.domain.model.FreemiumChargeLimitsStrategy object StepCompletionFeature { fun createState(step: Step, stepRoute: StepRoute): State = @@ -96,7 +97,7 @@ object StepCompletionFeature { * Show problem of day solve modal */ data class ProblemOfDaySolved( - val earnedGemsText: String, + val earnedGemsText: String?, val shareStreakData: ShareStreakData ) : Message object ProblemOfDaySolvedModalGoBackClicked : Message @@ -118,6 +119,11 @@ object StepCompletionFeature { object InterviewPreparationCompletedModalHiddenEventMessage : Message object InterviewPreparationCompletedModalGoToTrainingClicked : Message + /** + * Ask user to rate or review the app + */ + object RequestUserReview : Message + /** * Analytic */ @@ -140,7 +146,7 @@ object StepCompletionFeature { data class CheckTopicCompletionStatus(val topicId: Long) : Action - object UpdateProblemsLimit : Action + data class UpdateProblemsLimit(val chargeStrategy: FreemiumChargeLimitsStrategy) : Action object UpdateLastTimeShareStreakShown : Action @@ -151,7 +157,7 @@ object StepCompletionFeature { ) : ViewAction data class ShowProblemOfDaySolvedModal( - val earnedGemsText: String, + val earnedGemsText: String?, val shareStreakData: ShareStreakData ) : ViewAction @@ -160,6 +166,8 @@ object StepCompletionFeature { object ShowInterviewPreparationCompletedModal : ViewAction + data class ShowRequestUserReviewModal(val stepRoute: StepRoute) : ViewAction + data class ShowStartPracticingError(val message: String) : ViewAction data class ReloadStep(val stepRoute: StepRoute) : ViewAction diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionReducer.kt index f45cedf40d..51213e6b0d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionReducer.kt @@ -27,6 +27,8 @@ import org.hyperskill.app.step_completion.presentation.StepCompletionFeature.Int import org.hyperskill.app.step_completion.presentation.StepCompletionFeature.InternalMessage import org.hyperskill.app.step_completion.presentation.StepCompletionFeature.Message import org.hyperskill.app.step_completion.presentation.StepCompletionFeature.State +import org.hyperskill.app.step_quiz.presentation.StepQuizResolver +import org.hyperskill.app.subscriptions.domain.model.FreemiumChargeLimitsStrategy import ru.nobird.app.presentation.redux.reducer.StateReducer private typealias StepCompletionReducerResult = Pair> @@ -206,6 +208,9 @@ class StepCompletionReducer(private val stepRoute: StepRoute) : StateReducer { + state to setOf(Action.ViewAction.ShowRequestUserReviewModal(stepRoute)) + } /** * Analytic * */ @@ -294,15 +299,14 @@ class StepCompletionReducer(private val stepRoute: StepRoute) : StateReducer - state to setOf(Action.UpdateProblemsLimit) - is StepRoute.InterviewPreparation -> - state to setOf( - Action.UpdateProblemsLimit, - InternalAction.MarkInterviewStepAsSolved(message.stepId) - ) - else -> state to emptySet() + state to buildSet { + if (StepQuizResolver.isStepHasLimitedAttempts(stepRoute)) { + add(Action.UpdateProblemsLimit(FreemiumChargeLimitsStrategy.AFTER_CORRECT_SUBMISSION)) + } + + if (stepRoute is StepRoute.InterviewPreparation) { + add(InternalAction.MarkInterviewStepAsSolved(message.stepId)) + } } } else { state to emptySet() diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/data/repository/SubmissionRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/data/repository/SubmissionRepositoryImpl.kt index 9bdb954a27..c693665684 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/data/repository/SubmissionRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/data/repository/SubmissionRepositoryImpl.kt @@ -1,6 +1,5 @@ package org.hyperskill.app.step_quiz.data.repository -import kotlinx.coroutines.flow.MutableSharedFlow import org.hyperskill.app.step.domain.model.StepContext import org.hyperskill.app.step_quiz.data.source.SubmissionCacheDataSource import org.hyperskill.app.step_quiz.data.source.SubmissionRemoteDataSource @@ -8,12 +7,10 @@ import org.hyperskill.app.step_quiz.domain.model.submissions.Reply import org.hyperskill.app.step_quiz.domain.model.submissions.Submission import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository -class SubmissionRepositoryImpl( +internal class SubmissionRepositoryImpl( private val submissionRemoteDataSource: SubmissionRemoteDataSource, private val submissionCacheDataSource: SubmissionCacheDataSource ) : SubmissionRepository { - override val solvedStepsMutableSharedFlow: MutableSharedFlow = MutableSharedFlow() - override suspend fun getSubmissionsForStep( stepId: Long, userId: Long, @@ -31,9 +28,8 @@ class SubmissionRepositoryImpl( ): Result = submissionRemoteDataSource.createSubmission(attemptId, reply, solvingContext) - override suspend fun notifyStepSolved(stepId: Long) { + override fun incrementSolvedStepsCount() { submissionCacheDataSource.incrementSolvedStepsCount() - solvedStepsMutableSharedFlow.emit(stepId) } override fun getSolvedStepsCount(): Long = diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/analytic/ProblemsLimitReachedModalClickedUnlockUnlimitedProblemsHSAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/analytic/ProblemsLimitReachedModalClickedUnlockUnlimitedProblemsHSAnalyticEvent.kt new file mode 100644 index 0000000000..eda6bea8f0 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/analytic/ProblemsLimitReachedModalClickedUnlockUnlimitedProblemsHSAnalyticEvent.kt @@ -0,0 +1,30 @@ +package org.hyperskill.app.step_quiz.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents click on the "Unlock unlimited problems" button in problems limit reached modal analytic event. + * + * JSON payload: + * ``` + * { + * "route": "/learn/step/1", + * "action": "click", + * "part": "problems_limit_reached_modal", + * "target": "unlock_unlimited_problems" + * } + * ``` + * @see HyperskillAnalyticEvent + */ +class ProblemsLimitReachedModalClickedUnlockUnlimitedProblemsHSAnalyticEvent( + route: HyperskillAnalyticRoute +) : HyperskillAnalyticEvent( + route, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.PROBLEMS_LIMIT_REACHED_MODAL, + HyperskillAnalyticTarget.UNLOCK_UNLIMITED_PROBLEMS +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/analytic/StepQuizUnsupportedClickedGoToStudyPlanHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/analytic/StepQuizUnsupportedClickedGoToStudyPlanHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..d92747e0dc --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/analytic/StepQuizUnsupportedClickedGoToStudyPlanHyperskillAnalyticEvent.kt @@ -0,0 +1,31 @@ +package org.hyperskill.app.step_quiz.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents click on the "Go to Study Plan" button analytic event after unsupported quiz placeholder. + * + * JSON payload: + * ``` + * { + * "route": "/learn/step/1", + * "action": "click", + * "part": "unsupported_quiz_placeholder", + * "target": "go_to_study_plan" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class StepQuizUnsupportedClickedGoToStudyPlanHyperskillAnalyticEvent( + route: HyperskillAnalyticRoute +) : HyperskillAnalyticEvent( + route, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.UNSUPPORTED_QUIZ_PLACEHOLDER, + HyperskillAnalyticTarget.GO_TO_STUDY_PLAN +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/analytic/StepQuizUnsupportedClickedSolveOnTheWebHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/analytic/StepQuizUnsupportedClickedSolveOnTheWebHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..8ff46517bb --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/analytic/StepQuizUnsupportedClickedSolveOnTheWebHyperskillAnalyticEvent.kt @@ -0,0 +1,31 @@ +package org.hyperskill.app.step_quiz.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents click on the "Solve on the Web version" button analytic event after unsupported quiz placeholder. + * + * JSON payload: + * ``` + * { + * "route": "/learn/step/1", + * "action": "click", + * "part": "unsupported_quiz_placeholder", + * "target": "solve_on_the_web_version" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class StepQuizUnsupportedClickedSolveOnTheWebHyperskillAnalyticEvent( + route: HyperskillAnalyticRoute +) : HyperskillAnalyticEvent( + route, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.UNSUPPORTED_QUIZ_PLACEHOLDER, + HyperskillAnalyticTarget.SOLVE_ON_THE_WEB_VERSION +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/interactor/StepQuizInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/interactor/StepQuizInteractor.kt index 094b3f4d58..ce918bb4e2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/interactor/StepQuizInteractor.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/interactor/StepQuizInteractor.kt @@ -3,6 +3,7 @@ package org.hyperskill.app.step_quiz.domain.interactor import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay import org.hyperskill.app.step.domain.model.StepContext +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow import org.hyperskill.app.step_quiz.domain.model.attempts.Attempt import org.hyperskill.app.step_quiz.domain.model.attempts.AttemptStatus import org.hyperskill.app.step_quiz.domain.model.submissions.Reply @@ -13,7 +14,8 @@ import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository class StepQuizInteractor( private val attemptRepository: AttemptRepository, - private val submissionRepository: SubmissionRepository + private val submissionRepository: SubmissionRepository, + private val stepCompletedFlow: StepCompletedFlow ) { companion object { private val POLL_SUBMISSION_INTERVAL = 1.seconds @@ -59,7 +61,8 @@ class StepQuizInteractor( } if (evaluatedSubmission.status == SubmissionStatus.CORRECT) { - submissionRepository.notifyStepSolved(stepId) + submissionRepository.incrementSolvedStepsCount() + stepCompletedFlow.notifyDataChanged(stepId) } return Result.success(evaluatedSubmission) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/repository/SubmissionRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/repository/SubmissionRepository.kt index de62be087f..a62861a05d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/repository/SubmissionRepository.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/repository/SubmissionRepository.kt @@ -1,17 +1,10 @@ package org.hyperskill.app.step_quiz.domain.repository -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow import org.hyperskill.app.step.domain.model.StepContext import org.hyperskill.app.step_quiz.domain.model.submissions.Reply import org.hyperskill.app.step_quiz.domain.model.submissions.Submission interface SubmissionRepository { - val solvedStepsMutableSharedFlow: MutableSharedFlow - - val solvedStepsSharedFlow: SharedFlow - get() = solvedStepsMutableSharedFlow - suspend fun getSubmissionsForStep( stepId: Long, userId: Long, @@ -35,7 +28,6 @@ interface SubmissionRepository { solvingContext: StepContext ): Result - suspend fun notifyStepSolved(stepId: Long) - + fun incrementSolvedStepsCount() fun getSolvedStepsCount(): Long } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/serialization/choice_answer/ChoiceAnswerContentSerializer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/serialization/choice_answer/ChoiceAnswerContentSerializer.kt index a833dddbd7..924f62e696 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/serialization/choice_answer/ChoiceAnswerContentSerializer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/serialization/choice_answer/ChoiceAnswerContentSerializer.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.json.JsonObject import org.hyperskill.app.step_quiz.domain.model.submissions.ChoiceAnswer object ChoiceAnswerContentSerializer : JsonContentPolymorphicSerializer(ChoiceAnswer::class) { - override fun selectDeserializer(element: JsonElement): DeserializationStrategy = + override fun selectDeserializer(element: JsonElement): DeserializationStrategy = if (element is JsonObject) { ChoiceAnswer.Table.serializer() } else { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/serialization/feedback/FeedbackContentSerializer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/serialization/feedback/FeedbackContentSerializer.kt index ee4081c077..143f45f225 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/serialization/feedback/FeedbackContentSerializer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/domain/serialization/feedback/FeedbackContentSerializer.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.json.JsonObject import org.hyperskill.app.step_quiz.domain.model.submissions.Feedback object FeedbackContentSerializer : JsonContentPolymorphicSerializer(Feedback::class) { - override fun selectDeserializer(element: JsonElement): DeserializationStrategy = + override fun selectDeserializer(element: JsonElement): DeserializationStrategy = if (element is JsonObject) { Feedback.Object.serializer() } else { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizComponentImpl.kt index 7fb32c3b25..1aebb2d1d5 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizComponentImpl.kt @@ -14,7 +14,7 @@ import org.hyperskill.app.step_quiz.view.mapper.StepQuizTitleMapper import org.hyperskill.app.step_quiz_hints.injection.StepQuizHintsComponent import ru.nobird.app.presentation.redux.feature.Feature -class StepQuizComponentImpl( +internal class StepQuizComponentImpl( private val appGraph: AppGraph, private val stepRoute: StepRoute ) : StepQuizComponent { @@ -30,16 +30,16 @@ class StepQuizComponentImpl( override val stepQuizTitleMapper: StepQuizTitleMapper get() = StepQuizTitleMapper(appGraph.commonComponent.resourceProvider) - private val attemptRemoteDataSource: AttemptRemoteDataSource = AttemptRemoteDataSourceImpl( - appGraph.networkComponent.authorizedHttpClient - ) + private val attemptRemoteDataSource: AttemptRemoteDataSource = + AttemptRemoteDataSourceImpl(appGraph.networkComponent.authorizedHttpClient) private val attemptRepository: AttemptRepository = AttemptRepositoryImpl(attemptRemoteDataSource) override val stepQuizInteractor: StepQuizInteractor = StepQuizInteractor( - attemptRepository, - appGraph.submissionDataComponent.submissionRepository + attemptRepository = attemptRepository, + submissionRepository = appGraph.buildSubmissionDataComponent().submissionRepository, + stepCompletedFlow = appGraph.stepCompletionFlowDataComponent.stepCompletedFlow ) private val stepQuizHintsComponent: StepQuizHintsComponent = @@ -47,18 +47,20 @@ class StepQuizComponentImpl( override val stepQuizFeature: Feature get() = StepQuizFeatureBuilder.build( - stepRoute, - stepQuizInteractor, - stepQuizReplyValidator, - appGraph.profileDataComponent.currentProfileStateRepository, - appGraph.buildFreemiumDataComponent().freemiumInteractor, - appGraph.analyticComponent.analyticInteractor, - appGraph.sentryComponent.sentryInteractor, - appGraph.buildOnboardingDataComponent().onboardingInteractor, - stepQuizHintsComponent.stepQuizHintsReducer, - stepQuizHintsComponent.stepQuizHintsActionDispatcher, - appGraph.commonComponent.resourceProvider, - appGraph.loggerComponent.logger, - appGraph.commonComponent.buildKonfig.buildVariant + stepRoute = stepRoute, + stepQuizInteractor = stepQuizInteractor, + stepQuizReplyValidator = stepQuizReplyValidator, + subscriptionsInteractor = appGraph.subscriptionDataComponent.subscriptionsInteractor, + currentProfileStateRepository = appGraph.profileDataComponent.currentProfileStateRepository, + urlPathProcessor = appGraph.buildMagicLinksDataComponent().urlPathProcessor, + analyticInteractor = appGraph.analyticComponent.analyticInteractor, + sentryInteractor = appGraph.sentryComponent.sentryInteractor, + onboardingInteractor = appGraph.buildOnboardingDataComponent().onboardingInteractor, + stepQuizHintsReducer = stepQuizHintsComponent.stepQuizHintsReducer, + stepQuizHintsActionDispatcher = stepQuizHintsComponent.stepQuizHintsActionDispatcher, + resourceProvider = appGraph.commonComponent.resourceProvider, + logger = appGraph.loggerComponent.logger, + buildVariant = appGraph.commonComponent.buildKonfig.buildVariant, + platform = appGraph.commonComponent.platform ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizFeatureBuilder.kt index aae15fe0b8..4dab112cc5 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/StepQuizFeatureBuilder.kt @@ -3,10 +3,11 @@ package org.hyperskill.app.step_quiz.injection import co.touchlab.kermit.Logger import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.core.domain.BuildVariant +import org.hyperskill.app.core.domain.platform.Platform import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.freemium.domain.interactor.FreemiumInteractor import org.hyperskill.app.logging.presentation.wrapWithLogger +import org.hyperskill.app.magic_links.domain.interactor.UrlPathProcessor import org.hyperskill.app.onboarding.domain.interactor.OnboardingInteractor import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.sentry.domain.interactor.SentryInteractor @@ -19,21 +20,23 @@ import org.hyperskill.app.step_quiz.presentation.StepQuizReducer import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsActionDispatcher import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsFeature import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsReducer +import org.hyperskill.app.subscriptions.domain.interactor.SubscriptionsInteractor import ru.nobird.app.core.model.safeCast import ru.nobird.app.presentation.redux.dispatcher.transform import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher import ru.nobird.app.presentation.redux.feature.Feature import ru.nobird.app.presentation.redux.feature.ReduxFeature -object StepQuizFeatureBuilder { +internal object StepQuizFeatureBuilder { private const val LOG_TAG = "StepQuizFeature" fun build( stepRoute: StepRoute, stepQuizInteractor: StepQuizInteractor, stepQuizReplyValidator: StepQuizReplyValidator, + subscriptionsInteractor: SubscriptionsInteractor, currentProfileStateRepository: CurrentProfileStateRepository, - freemiumInteractor: FreemiumInteractor, + urlPathProcessor: UrlPathProcessor, analyticInteractor: AnalyticInteractor, sentryInteractor: SentryInteractor, onboardingInteractor: OnboardingInteractor, @@ -41,22 +44,25 @@ object StepQuizFeatureBuilder { stepQuizHintsActionDispatcher: StepQuizHintsActionDispatcher, resourceProvider: ResourceProvider, logger: Logger, - buildVariant: BuildVariant + buildVariant: BuildVariant, + platform: Platform ): Feature { val stepQuizReducer = StepQuizReducer( stepRoute = stepRoute, stepQuizHintsReducer = stepQuizHintsReducer ).wrapWithLogger(buildVariant, logger, LOG_TAG) val stepQuizActionDispatcher = StepQuizActionDispatcher( - ActionDispatcherOptions(), - stepQuizInteractor, - stepQuizReplyValidator, - currentProfileStateRepository, - freemiumInteractor, - analyticInteractor, - sentryInteractor, - onboardingInteractor, - resourceProvider + config = ActionDispatcherOptions(), + stepQuizInteractor = stepQuizInteractor, + stepQuizReplyValidator = stepQuizReplyValidator, + subscriptionsInteractor = subscriptionsInteractor, + currentProfileStateRepository = currentProfileStateRepository, + urlPathProcessor = urlPathProcessor, + analyticInteractor = analyticInteractor, + sentryInteractor = sentryInteractor, + onboardingInteractor = onboardingInteractor, + resourceProvider = resourceProvider, + platform = platform ) return ReduxFeature( diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/SubmissionDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/SubmissionDataComponentImpl.kt index 6927df76eb..506bf96a40 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/SubmissionDataComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/injection/SubmissionDataComponentImpl.kt @@ -6,7 +6,7 @@ import org.hyperskill.app.step_quiz.data.repository.SubmissionRepositoryImpl import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository import org.hyperskill.app.step_quiz.remote.SubmissionRemoteDataSourceImpl -class SubmissionDataComponentImpl( +internal class SubmissionDataComponentImpl( appGraph: AppGraph ) : SubmissionDataComponent { override val submissionRepository: SubmissionRepository = diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizActionDispatcher.kt index b42a05b1ca..7583ff8689 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizActionDispatcher.kt @@ -2,103 +2,53 @@ package org.hyperskill.app.step_quiz.presentation import org.hyperskill.app.SharedResources import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.core.domain.platform.Platform +import org.hyperskill.app.core.domain.url.HyperskillUrlPath import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.core.view.mapper.ResourceProvider -import org.hyperskill.app.freemium.domain.interactor.FreemiumInteractor +import org.hyperskill.app.magic_links.domain.interactor.UrlPathProcessor import org.hyperskill.app.onboarding.domain.interactor.OnboardingInteractor +import org.hyperskill.app.profile.domain.model.Profile +import org.hyperskill.app.profile.domain.model.isFreemiumWrongSubmissionChargeLimitsEnabled +import org.hyperskill.app.profile.domain.model.isMobileOnlySubscriptionEnabled import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository +import org.hyperskill.app.profile.domain.repository.isFreemiumWrongSubmissionChargeLimitsEnabled import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder +import org.hyperskill.app.sentry.domain.withTransaction import org.hyperskill.app.step_quiz.domain.interactor.StepQuizInteractor import org.hyperskill.app.step_quiz.domain.model.attempts.Attempt import org.hyperskill.app.step_quiz.domain.model.submissions.SubmissionStatus import org.hyperskill.app.step_quiz.domain.model.submissions.isWrongOrRejected import org.hyperskill.app.step_quiz.domain.validation.StepQuizReplyValidator import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.Action +import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.InternalAction +import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.InternalMessage import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.Message import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksMode +import org.hyperskill.app.subscriptions.domain.interactor.SubscriptionsInteractor +import org.hyperskill.app.subscriptions.domain.model.Subscription +import org.hyperskill.app.subscriptions.domain.model.isFreemium +import org.hyperskill.app.subscriptions.domain.model.isProblemsLimitReached import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher -class StepQuizActionDispatcher( +internal class StepQuizActionDispatcher( config: ActionDispatcherOptions, private val stepQuizInteractor: StepQuizInteractor, private val stepQuizReplyValidator: StepQuizReplyValidator, + private val subscriptionsInteractor: SubscriptionsInteractor, private val currentProfileStateRepository: CurrentProfileStateRepository, - private val freemiumInteractor: FreemiumInteractor, + private val urlPathProcessor: UrlPathProcessor, private val analyticInteractor: AnalyticInteractor, private val sentryInteractor: SentryInteractor, private val onboardingInteractor: OnboardingInteractor, - private val resourceProvider: ResourceProvider + private val resourceProvider: ResourceProvider, + private val platform: Platform ) : CoroutineActionDispatcher(config.createConfig()) { override suspend fun doSuspendableAction(action: Action) { when (action) { - is Action.FetchAttempt -> { - val sentryTransaction = HyperskillSentryTransactionBuilder.buildStepQuizScreenRemoteDataLoading() - sentryInteractor.startTransaction(sentryTransaction) - - val currentProfile = currentProfileStateRepository - .getState(forceUpdate = false) - .getOrElse { - sentryInteractor.finishTransaction(sentryTransaction, throwable = it) - onNewMessage(Message.FetchAttemptError(it)) - return - } - - val isProblemsLimitReached = freemiumInteractor - .isProblemsLimitReached() - .getOrElse { - sentryInteractor.finishTransaction(sentryTransaction, throwable = it) - onNewMessage(Message.FetchAttemptError(it)) - return - } - - val problemsLimitReachedModalText = freemiumInteractor - .getStepsLimitTotal() - .map { - it?.let { stepsLimitTotal -> - resourceProvider.getString( - SharedResources.strings.problems_limit_reached_modal_description, - stepsLimitTotal - ) - } - } - .getOrElse { - sentryInteractor.finishTransaction(sentryTransaction, throwable = it) - onNewMessage(Message.FetchAttemptError(it)) - return - } - - val message = stepQuizInteractor - .getAttempt(action.step.id, currentProfile.id) - .fold( - onSuccess = { attempt -> - val message = getSubmissionState(attempt.id, action.step.id, currentProfile.id).fold( - onSuccess = { - Message.FetchAttemptSuccess( - step = action.step, - attempt = attempt, - submissionState = it, - isProblemsLimitReached = isProblemsLimitReached, - problemsLimitReachedModalText = problemsLimitReachedModalText, - problemsOnboardingFlags = onboardingInteractor.getProblemsOnboardingFlags() - ) - }, - onFailure = { - Message.FetchAttemptError(it) - } - ) - message - }, - onFailure = { Message.FetchAttemptError(it) } - ) - - sentryInteractor.finishTransaction( - transaction = sentryTransaction, - throwable = (message as? Message.FetchAttemptError)?.throwable - ) - - onNewMessage(message) - } + is Action.FetchAttempt -> + handleFetchAttempt(action, ::onNewMessage) is Action.CreateAttempt -> { if (StepQuizResolver.isNeedRecreateAttemptForNewSubmission(action.step)) { val sentryTransaction = HyperskillSentryTransactionBuilder.buildStepQuizCreateAttempt() @@ -208,12 +158,69 @@ class StepQuizActionDispatcher( } } } + is InternalAction.UpdateProblemsLimit -> + handleUpdateProblemsLimitAction(action, ::onNewMessage) + is InternalAction.CreateMagicLinkForUnsupportedQuiz -> { + urlPathProcessor + .processUrlPath(HyperskillUrlPath.Step(action.stepRoute)) + .fold( + onSuccess = { onNewMessage(InternalMessage.CreateMagicLinkForUnsupportedQuizSuccess(it)) }, + onFailure = { onNewMessage(InternalMessage.CreateMagicLinkForUnsupportedQuizError) } + ) + } is Action.LogAnalyticEvent -> analyticInteractor.logEvent(action.analyticEvent) else -> {} } } + private suspend fun handleFetchAttempt( + action: Action.FetchAttempt, + onNewMessage: (Message) -> Unit + ) { + sentryInteractor.withTransaction( + transaction = HyperskillSentryTransactionBuilder.buildStepQuizScreenRemoteDataLoading(), + onError = { Message.FetchAttemptError(it) } + ) { + val currentSubscription = + subscriptionsInteractor.getCurrentSubscription() + .getOrThrow() + + val currentProfile = + currentProfileStateRepository + .getState() + .getOrThrow() + + val attempt = + stepQuizInteractor + .getAttempt(action.step.id, currentProfile.id) + .getOrThrow() + + val submissionState = + getSubmissionState(attempt.id, action.step.id, currentProfile.id) + .getOrThrow() + + val isProblemsLimitReached = currentSubscription.isProblemsLimitReached + val problemsLimitReachedModalData = if (isProblemsLimitReached) { + getProblemsLimitReachedModalData( + currentSubscription, + isSubscriptionPurchaseEnabled(currentProfile, currentSubscription) + ) + } else { + null + } + + Message.FetchAttemptSuccess( + step = action.step, + attempt = attempt, + submissionState = submissionState, + isProblemsLimitReached = isProblemsLimitReached, + problemsLimitReachedModalData = problemsLimitReachedModalData, + problemsOnboardingFlags = onboardingInteractor.getProblemsOnboardingFlags() + ) + }.let(onNewMessage) + } + private suspend fun getSubmissionState( attemptId: Long, stepId: Long, @@ -228,4 +235,89 @@ class StepQuizActionDispatcher( StepQuizFeature.SubmissionState.Loaded(submission) } } + + internal fun isSubscriptionPurchaseEnabled( + currentProfile: Profile, + currentSubscription: Subscription + ): Boolean = + platform.isSubscriptionPurchaseEnabled && + currentProfile.features.isMobileOnlySubscriptionEnabled && + currentSubscription.isFreemium + + private suspend fun getProblemsLimitReachedModalData( + subscription: Subscription, + isSubscriptionPurchaseEnabled: Boolean + ): StepQuizFeature.ProblemsLimitReachedModalData? { + val stepsLimitTotal = subscription.stepsLimitTotal ?: return null + + return if (currentProfileStateRepository.isFreemiumWrongSubmissionChargeLimitsEnabled()) { + StepQuizFeature.ProblemsLimitReachedModalData( + title = resourceProvider.getString( + SharedResources.strings.problems_limit_reached_modal_no_lives_left_title, + stepsLimitTotal + ), + description = resourceProvider.getString( + if (isSubscriptionPurchaseEnabled) { + SharedResources.strings.problems_limit_reached_modal_unlock_unlimited_lives_description + } else { + SharedResources.strings.problems_limit_reached_modal_no_lives_left_description + } + ), + unlockLimitsButtonText = if (isSubscriptionPurchaseEnabled) { + resourceProvider.getString( + SharedResources.strings.problems_limit_reached_modal_unlock_unlimited_lives_button + ) + } else { + null + } + ) + } else { + StepQuizFeature.ProblemsLimitReachedModalData( + title = resourceProvider.getString(SharedResources.strings.problems_limit_reached_modal_title), + description = resourceProvider.getString( + if (isSubscriptionPurchaseEnabled) { + SharedResources.strings.problems_limit_reached_modal_unlock_unlimited_problems_description + } else { + SharedResources.strings.problems_limit_reached_modal_description + }, + stepsLimitTotal + ), + unlockLimitsButtonText = if (isSubscriptionPurchaseEnabled) { + resourceProvider.getString( + SharedResources.strings.problems_limit_reached_modal_unlock_unlimited_problems_button + ) + } else { + null + } + ) + } + } + + private suspend fun handleUpdateProblemsLimitAction( + action: InternalAction.UpdateProblemsLimit, + onNewMessage: (Message) -> Unit + ) { + val currentProfile = currentProfileStateRepository.getState().getOrElse { return } + if (!currentProfile.features.isFreemiumWrongSubmissionChargeLimitsEnabled) return + + subscriptionsInteractor.chargeProblemsLimits(action.chargeStrategy) + + val subscription = subscriptionsInteractor.getCurrentSubscription().getOrElse { return } + val problemsLimitReachedModalData = + if (subscription.isProblemsLimitReached) { + getProblemsLimitReachedModalData( + subscription = subscription, + isSubscriptionPurchaseEnabled = isSubscriptionPurchaseEnabled(currentProfile, subscription) + ) + } else { + null + } + + onNewMessage( + InternalMessage.UpdateProblemsLimitResult( + isProblemsLimitReached = subscription.isProblemsLimitReached, + problemsLimitReachedModalData = problemsLimitReachedModalData + ) + ) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizFeature.kt index 7416c2a980..8351632140 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizFeature.kt @@ -3,6 +3,7 @@ package org.hyperskill.app.step_quiz.presentation import kotlinx.serialization.Serializable import org.hyperskill.app.analytic.domain.model.AnalyticEvent import org.hyperskill.app.onboarding.domain.model.ProblemsOnboardingFlags +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource import org.hyperskill.app.step.domain.model.Step import org.hyperskill.app.step.domain.model.StepContext import org.hyperskill.app.step.domain.model.StepRoute @@ -12,8 +13,9 @@ import org.hyperskill.app.step_quiz.domain.model.submissions.Submission import org.hyperskill.app.step_quiz.domain.validation.ReplyValidationResult import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksMode import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsFeature +import org.hyperskill.app.subscriptions.domain.model.FreemiumChargeLimitsStrategy -interface StepQuizFeature { +object StepQuizFeature { data class State( val stepQuizState: StepQuizState, val stepQuizHintsState: StepQuizHintsFeature.State @@ -51,6 +53,13 @@ interface StepQuizFeature { data class FillBlanks(val mode: FillBlanksMode) : ProblemOnboardingModal } + @Serializable + data class ProblemsLimitReachedModalData( + val title: String, + val description: String, + val unlockLimitsButtonText: String? + ) + sealed interface Message { data class InitWithStep(val step: Step, val forceUpdate: Boolean = false) : Message data class FetchAttemptSuccess( @@ -58,7 +67,7 @@ interface StepQuizFeature { val attempt: Attempt, val submissionState: SubmissionState, val isProblemsLimitReached: Boolean, - val problemsLimitReachedModalText: String?, + val problemsLimitReachedModalData: ProblemsLimitReachedModalData?, val problemsOnboardingFlags: ProblemsOnboardingFlags ) : Message data class FetchAttemptError(val throwable: Throwable) : Message @@ -99,6 +108,8 @@ interface StepQuizFeature { */ object ProblemsLimitReachedModalGoToHomeScreenClicked : Message + object ProblemsLimitReachedModalUnlockUnlimitedProblemsClicked : Message + /** * Problem onboarding modal */ @@ -112,6 +123,9 @@ interface StepQuizFeature { */ object TheoryToolbarItemClicked : Message + object UnsupportedQuizSolveOnTheWebClicked : Message + object UnsupportedQuizGoToStudyPlanClicked : Message + /** * Analytic */ @@ -136,6 +150,16 @@ interface StepQuizFeature { data class StepQuizHintsMessage(val message: StepQuizHintsFeature.Message) : Message } + internal sealed interface InternalMessage : Message { + data class UpdateProblemsLimitResult( + val isProblemsLimitReached: Boolean, + val problemsLimitReachedModalData: ProblemsLimitReachedModalData? + ) : InternalMessage + + object CreateMagicLinkForUnsupportedQuizError : InternalMessage + data class CreateMagicLinkForUnsupportedQuizSuccess(val url: String) : InternalMessage + } + sealed interface Action { data class FetchAttempt(val step: Step) : Action @@ -172,7 +196,7 @@ interface StepQuizFeature { object RequestResetCode : ViewAction - data class ShowProblemsLimitReachedModal(val modalText: String) : ViewAction + data class ShowProblemsLimitReachedModal(val modalData: ProblemsLimitReachedModalData) : ViewAction data class ShowProblemOnboardingModal(val modalType: ProblemOnboardingModal) : ViewAction @@ -180,11 +204,27 @@ interface StepQuizFeature { val viewAction: StepQuizHintsFeature.Action.ViewAction ) : ViewAction + sealed interface CreateMagicLinkState : ViewAction { + object Loading : CreateMagicLinkState + object Error : CreateMagicLinkState + object Success : CreateMagicLinkState + } + data class OpenUrl(val url: String) : ViewAction + sealed interface NavigateTo : ViewAction { object Home : NavigateTo + object StudyPlan : NavigateTo data class StepScreen(val stepRoute: StepRoute) : NavigateTo + + data class Paywall(val paywallTransitionSource: PaywallTransitionSource) : NavigateTo } } } + + internal sealed interface InternalAction : Action { + data class UpdateProblemsLimit(val chargeStrategy: FreemiumChargeLimitsStrategy) : InternalAction + + data class CreateMagicLinkForUnsupportedQuiz(val stepRoute: StepRoute) : InternalAction + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizReducer.kt index eb8660bca6..b2b36cb701 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizReducer.kt @@ -2,12 +2,14 @@ package org.hyperskill.app.step_quiz.presentation import kotlinx.datetime.Clock import org.hyperskill.app.onboarding.domain.model.ProblemsOnboardingFlags +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource import org.hyperskill.app.step.domain.model.BlockName import org.hyperskill.app.step.domain.model.Step import org.hyperskill.app.step.domain.model.StepRoute import org.hyperskill.app.step_quiz.domain.analytic.ProblemOnboardingModalHiddenHyperskillAnalyticEvent import org.hyperskill.app.step_quiz.domain.analytic.ProblemOnboardingModalShownHyperskillAnalyticEvent import org.hyperskill.app.step_quiz.domain.analytic.ProblemsLimitReachedModalClickedGoToHomeScreenHyperskillAnalyticEvent +import org.hyperskill.app.step_quiz.domain.analytic.ProblemsLimitReachedModalClickedUnlockUnlimitedProblemsHSAnalyticEvent import org.hyperskill.app.step_quiz.domain.analytic.ProblemsLimitReachedModalHiddenHyperskillAnalyticEvent import org.hyperskill.app.step_quiz.domain.analytic.ProblemsLimitReachedModalShownHyperskillAnalyticEvent import org.hyperskill.app.step_quiz.domain.analytic.StepQuizClickedCodeDetailsHyperskillAnalyticEvent @@ -20,12 +22,16 @@ import org.hyperskill.app.step_quiz.domain.analytic.StepQuizClickedTheoryToolbar import org.hyperskill.app.step_quiz.domain.analytic.StepQuizCodeEditorClickedInputAccessoryButtonHyperskillAnalyticEvent import org.hyperskill.app.step_quiz.domain.analytic.StepQuizFullScreenCodeEditorClickedCodeDetailsHyperskillAnalyticEvent import org.hyperskill.app.step_quiz.domain.analytic.StepQuizFullScreenCodeEditorClickedStepTextDetailsHyperskillAnalyticEvent +import org.hyperskill.app.step_quiz.domain.analytic.StepQuizUnsupportedClickedGoToStudyPlanHyperskillAnalyticEvent +import org.hyperskill.app.step_quiz.domain.analytic.StepQuizUnsupportedClickedSolveOnTheWebHyperskillAnalyticEvent import org.hyperskill.app.step_quiz.domain.model.attempts.Attempt import org.hyperskill.app.step_quiz.domain.model.submissions.Reply import org.hyperskill.app.step_quiz.domain.model.submissions.Submission import org.hyperskill.app.step_quiz.domain.model.submissions.SubmissionStatus import org.hyperskill.app.step_quiz.domain.validation.ReplyValidationResult import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.Action +import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.InternalAction +import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.InternalMessage import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.Message import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.State import org.hyperskill.app.step_quiz.presentation.StepQuizFeature.StepQuizState @@ -33,11 +39,12 @@ import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksMode import org.hyperskill.app.step_quiz_fill_blanks.presentation.FillBlanksResolver import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsFeature import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsReducer +import org.hyperskill.app.subscriptions.domain.model.FreemiumChargeLimitsStrategy import ru.nobird.app.presentation.redux.reducer.StateReducer internal typealias StepQuizReducerResult = Pair> -class StepQuizReducer( +internal class StepQuizReducer( private val stepRoute: StepRoute, private val stepQuizHintsReducer: StepQuizHintsReducer ) : StateReducer { @@ -156,7 +163,13 @@ class StepQuizReducer( attempt = message.newAttempt ?: state.stepQuizState.attempt, submissionState = StepQuizFeature.SubmissionState.Loaded(message.submission) ) - ) to emptySet() + ) to buildSet { + if (message.submission.status == SubmissionStatus.WRONG && + StepQuizResolver.isStepHasLimitedAttempts(stepRoute) + ) { + add(InternalAction.UpdateProblemsLimit(FreemiumChargeLimitsStrategy.AFTER_WRONG_SUBMISSION)) + } + } } else { null } @@ -205,6 +218,8 @@ class StepQuizReducer( null } } + is InternalMessage.UpdateProblemsLimitResult -> + handleUpdateProblemsLimitResult(state, message) is Message.ProblemsLimitReachedModalGoToHomeScreenClicked -> state to setOf( Action.ViewAction.NavigateTo.Home, @@ -212,6 +227,13 @@ class StepQuizReducer( ProblemsLimitReachedModalClickedGoToHomeScreenHyperskillAnalyticEvent(stepRoute.analyticRoute) ) ) + is Message.ProblemsLimitReachedModalUnlockUnlimitedProblemsClicked -> + state to setOf( + Action.ViewAction.NavigateTo.Paywall(PaywallTransitionSource.PROBLEMS_LIMIT_MODAL), + Action.LogAnalyticEvent( + ProblemsLimitReachedModalClickedUnlockUnlimitedProblemsHSAnalyticEvent(stepRoute.analyticRoute) + ) + ) is Message.ClickedCodeDetailsEventMessage -> if (state.stepQuizState is StepQuizState.AttemptLoaded) { val event = StepQuizClickedCodeDetailsHyperskillAnalyticEvent(stepRoute.analyticRoute) @@ -268,6 +290,28 @@ class StepQuizReducer( } is Message.TheoryToolbarItemClicked -> handleTheoryToolbarItemClicked(state) + Message.UnsupportedQuizGoToStudyPlanClicked -> + state to setOf( + Action.LogAnalyticEvent( + StepQuizUnsupportedClickedGoToStudyPlanHyperskillAnalyticEvent(stepRoute.analyticRoute) + ), + Action.ViewAction.NavigateTo.StudyPlan + ) + Message.UnsupportedQuizSolveOnTheWebClicked -> + state to setOf( + Action.ViewAction.CreateMagicLinkState.Loading, + InternalAction.CreateMagicLinkForUnsupportedQuiz(stepRoute), + Action.LogAnalyticEvent( + StepQuizUnsupportedClickedSolveOnTheWebHyperskillAnalyticEvent(stepRoute.analyticRoute) + ) + ) + InternalMessage.CreateMagicLinkForUnsupportedQuizError -> + state to setOf(Action.ViewAction.CreateMagicLinkState.Error) + is InternalMessage.CreateMagicLinkForUnsupportedQuizSuccess -> + state to setOf( + Action.ViewAction.CreateMagicLinkState.Success, + Action.ViewAction.OpenUrl(message.url) + ) is Message.ClickedRetryEventMessage -> if (state.stepQuizState is StepQuizState.AttemptLoaded) { val event = StepQuizClickedRetryHyperskillAnalyticEvent(stepRoute.analyticRoute) @@ -321,15 +365,12 @@ class StepQuizReducer( if (StepQuizResolver.isIdeRequired(message.step, message.submissionState)) { state.copy(stepQuizState = StepQuizState.Unsupported) to emptySet() } else { - val isProblemsLimitReached = when (stepRoute) { - is StepRoute.Repeat, - is StepRoute.LearnDaily -> false - else -> message.isProblemsLimitReached - } + val isProblemsLimitReached = + StepQuizResolver.isStepHasLimitedAttempts(stepRoute) && message.isProblemsLimitReached - val actions = if (isProblemsLimitReached && message.problemsLimitReachedModalText != null) { + val actions = if (isProblemsLimitReached && message.problemsLimitReachedModalData != null) { setOf( - Action.ViewAction.ShowProblemsLimitReachedModal(message.problemsLimitReachedModalText) + Action.ViewAction.ShowProblemsLimitReachedModal(message.problemsLimitReachedModalData) ) } else { getProblemOnboardingModalActions( @@ -380,6 +421,27 @@ class StepQuizReducer( ) to stepQuizActions + stepQuizHintsActions } + private fun handleUpdateProblemsLimitResult( + state: State, + message: InternalMessage.UpdateProblemsLimitResult + ): StepQuizReducerResult? = + if (state.stepQuizState is StepQuizState.AttemptLoaded) { + val isProblemsLimitReached = + StepQuizResolver.isStepHasLimitedAttempts(stepRoute) && message.isProblemsLimitReached + + state.copy( + stepQuizState = state.stepQuizState.copy( + isProblemsLimitReached = isProblemsLimitReached + ) + ) to buildSet { + if (isProblemsLimitReached && message.problemsLimitReachedModalData != null) { + add(Action.ViewAction.ShowProblemsLimitReachedModal(message.problemsLimitReachedModalData)) + } + } + } else { + null + } + private fun handleTheoryToolbarItemClicked(state: State): StepQuizReducerResult = if (state.stepQuizState is StepQuizState.AttemptLoaded && state.stepQuizState.isTheoryAvailable diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizResolver.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizResolver.kt index aefcbc4779..03de2864dc 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizResolver.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizResolver.kt @@ -123,6 +123,21 @@ object StepQuizResolver { } } + /** + * @return Returns `true` if step route has limited number of attempts. + */ + internal fun isStepHasLimitedAttempts(stepRoute: StepRoute): Boolean = + when (stepRoute) { + is StepRoute.Learn.Step, + is StepRoute.InterviewPreparation, + is StepRoute.StageImplement -> true + is StepRoute.Learn.TheoryOpenedFromPractice, + is StepRoute.Learn.TheoryOpenedFromSearch, + is StepRoute.LearnDaily, + is StepRoute.Repeat.Practice, + is StepRoute.Repeat.Theory -> false + } + /** * Is used to avoid unnecessary UI re-rendering each time on reply change. */ diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/injection/StepQuizHintsComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/injection/StepQuizHintsComponentImpl.kt index 6e1b43462f..5a4f455c65 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/injection/StepQuizHintsComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/injection/StepQuizHintsComponentImpl.kt @@ -29,7 +29,7 @@ class StepQuizHintsComponentImpl( commentsInteractor = appGraph.buildCommentsDataComponent().commentsInteractor, reactionsInteractor = appGraph.buildReactionsDataComponent().reactionsInteractor, userStorageInteractor = appGraph.buildUserStorageComponent().userStorageInteractor, - freemiumInteractor = appGraph.buildFreemiumDataComponent().freemiumInteractor, + currentSubscriptionStateRepository = appGraph.stateRepositoriesComponent.currentSubscriptionStateRepository, analyticInteractor = appGraph.analyticComponent.analyticInteractor, sentryInteractor = appGraph.sentryComponent.sentryInteractor ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/presentation/StepQuizHintsActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/presentation/StepQuizHintsActionDispatcher.kt index 44c7b926d7..24bee7eca8 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/presentation/StepQuizHintsActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/presentation/StepQuizHintsActionDispatcher.kt @@ -3,7 +3,6 @@ package org.hyperskill.app.step_quiz_hints.presentation import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.comments.domain.interactor.CommentsInteractor import org.hyperskill.app.core.presentation.ActionDispatcherOptions -import org.hyperskill.app.freemium.domain.interactor.FreemiumInteractor import org.hyperskill.app.likes.domain.interactor.LikesInteractor import org.hyperskill.app.reactions.domain.interactor.ReactionsInteractor import org.hyperskill.app.reactions.domain.model.ReactionType @@ -13,6 +12,7 @@ import org.hyperskill.app.step_quiz_hints.domain.interactor.StepQuizHintsInterac import org.hyperskill.app.step_quiz_hints.domain.model.HintState import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsFeature.Action import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsFeature.Message +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository import org.hyperskill.app.user_storage.domain.interactor.UserStorageInteractor import org.hyperskill.app.user_storage.domain.model.UserStoragePathBuilder import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher @@ -24,7 +24,7 @@ class StepQuizHintsActionDispatcher( private val commentsInteractor: CommentsInteractor, private val reactionsInteractor: ReactionsInteractor, private val userStorageInteractor: UserStorageInteractor, - private val freemiumInteractor: FreemiumInteractor, + private val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, private val analyticInteractor: AnalyticInteractor, private val sentryInteractor: SentryInteractor ) : CoroutineActionDispatcher(config.createConfig()) { @@ -36,7 +36,13 @@ class StepQuizHintsActionDispatcher( val hintsIds = stepQuizHintsInteractor.getNotSeenHintsIds(action.stepId) - val isFreemiumEnabled = freemiumInteractor.isFreemiumEnabled().getOrDefault(false) + val areHintsLimited = + currentSubscriptionStateRepository + .getState() + .getOrNull() + ?.type + ?.areHintsLimited + ?: false val lastSeenHint = stepQuizHintsInteractor.getLastSeenHint(action.stepId) @@ -56,7 +62,7 @@ class StepQuizHintsActionDispatcher( hintsIds = hintsIds, lastSeenHint = lastSeenHint, lastSeenHintHasReaction = lastSeenHintHasReaction, - isFreemiumEnabled = isFreemiumEnabled, + areHintsLimited = areHintsLimited, stepId = action.stepId ) ) @@ -119,7 +125,7 @@ class StepQuizHintsActionDispatcher( Message.NextHintLoaded( it, action.remainingHintsIds, - action.isFreemiumEnabled, + action.areHintsLimited, action.stepId ) ) @@ -136,7 +142,7 @@ class StepQuizHintsActionDispatcher( Message.NextHintLoadingError( action.nextHintId, action.remainingHintsIds, - action.isFreemiumEnabled, + action.areHintsLimited, action.stepId ) ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/presentation/StepQuizHintsFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/presentation/StepQuizHintsFeature.kt index b7edb66e57..71bdf00184 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/presentation/StepQuizHintsFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/presentation/StepQuizHintsFeature.kt @@ -24,14 +24,14 @@ object StepQuizHintsFeature { * @property hintsIds remaining hints to be displayed * @property currentHint current hint to be displayed * @property hintHasReaction flag true, if user created reaction or reported hint - * @property isFreemiumEnabled used for showing only one hint for freemium + * @property areHintsLimited used for showing only one hint for some subscriptions * @property stepId used for analytic route */ data class Content( val hintsIds: List, val currentHint: Comment?, val hintHasReaction: Boolean, - val isFreemiumEnabled: Boolean, + val areHintsLimited: Boolean, val stepId: Long ) : State @@ -40,13 +40,13 @@ object StepQuizHintsFeature { * * @property nextHintId next hint to be loaded * @property hintsIds remaining hints to be displayed - * @property isFreemiumEnabled used for showing only one hint for freemium + * @property areHintsLimited used for showing only one hint for some subscriptions * @property stepId used for analytic route */ data class NetworkError( val nextHintId: Long, val hintsIds: List, - val isFreemiumEnabled: Boolean, + val areHintsLimited: Boolean, val stepId: Long ) : State } @@ -89,14 +89,14 @@ object StepQuizHintsFeature { * Message to fill state with ready data * * @property hintsIds hints ids to be displayed - * @property isFreemiumEnabled used for showing only one hint for freemium + * @property areHintsLimited used for showing only one hint for some subscriptions * @property stepId used for analytic route */ data class HintsIdsLoaded( val hintsIds: List, val lastSeenHint: Comment?, val lastSeenHintHasReaction: Boolean, - val isFreemiumEnabled: Boolean, + val areHintsLimited: Boolean, val stepId: Long ) : Message @@ -142,13 +142,13 @@ object StepQuizHintsFeature { * * @property nextHint new loaded hint * @property remainingHintsIds next hints ids to be displayed - * @property isFreemiumEnabled used for showing only one hint for freemium + * @property areHintsLimited used for showing only one hint for some subscriptions * @property stepId used for analytic route */ data class NextHintLoaded( val nextHint: Comment, val remainingHintsIds: List, - val isFreemiumEnabled: Boolean, + val areHintsLimited: Boolean, val stepId: Long ) : Message @@ -157,13 +157,13 @@ object StepQuizHintsFeature { * * @property nextHintId next hint to be loaded * @property remainingHintsIds remaining hints ids - * @property isFreemiumEnabled used for showing only one hint for freemium + * @property areHintsLimited used for showing only one hint for some subscriptions * @property stepId used for analytic route */ data class NextHintLoadingError( val nextHintId: Long, val remainingHintsIds: List, - val isFreemiumEnabled: Boolean, + val areHintsLimited: Boolean, val stepId: Long ) : Message @@ -205,13 +205,13 @@ object StepQuizHintsFeature { * * @property nextHintId hint ID to load hint details * @property remainingHintsIds next hints ids to be displayed - * @property isFreemiumEnabled used for showing only one hint for freemium + * @property areHintsLimited used for showing only one hint for some subscriptions * @property stepId used for analytic route */ data class FetchNextHint( val nextHintId: Long, val remainingHintsIds: List, - val isFreemiumEnabled: Boolean, + val areHintsLimited: Boolean, val stepId: Long ) : Action diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/presentation/StepQuizHintsReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/presentation/StepQuizHintsReducer.kt index d877d1961c..c9a662ed13 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/presentation/StepQuizHintsReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz_hints/presentation/StepQuizHintsReducer.kt @@ -26,7 +26,7 @@ class StepQuizHintsReducer(private val stepRoute: StepRoute) : StateReducer { StepQuizHintsFeature.ViewState.HintState.REACT_TO_HINT } - state.hintHasReaction && state.hintsIds.isNotEmpty() && !state.isFreemiumEnabled -> { + state.hintHasReaction && state.hintsIds.isNotEmpty() && !state.areHintsLimited -> { StepQuizHintsFeature.ViewState.HintState.SEE_NEXT_HINT } else -> StepQuizHintsFeature.ViewState.HintState.LAST_HINT diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalClickedNoThanksHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalClickedNoThanksHyperskillAnalyticEvent.kt index 90a1f75a1a..98ecf823fc 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalClickedNoThanksHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalClickedNoThanksHyperskillAnalyticEvent.kt @@ -12,16 +12,17 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * JSON payload: * ``` * { - * "route": "/home", + * "route": "None", * "action": "click", * "part": "streak_recovery_modal", * "target": "no_thanks" * } * ``` + * * @see HyperskillAnalyticEvent */ class StreakRecoveryModalClickedNoThanksHyperskillAnalyticEvent : HyperskillAnalyticEvent( - HyperskillAnalyticRoute.Home(), + HyperskillAnalyticRoute.None, HyperskillAnalyticAction.CLICK, HyperskillAnalyticPart.STREAK_RECOVERY_MODAL, HyperskillAnalyticTarget.NO_THANKS diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalClickedRestoreStreakHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalClickedRestoreStreakHyperskillAnalyticEvent.kt index a54b6b8263..f2c23f9181 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalClickedRestoreStreakHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalClickedRestoreStreakHyperskillAnalyticEvent.kt @@ -12,16 +12,17 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * JSON payload: * ``` * { - * "route": "/home", + * "route": "None", * "action": "click", * "part": "streak_recovery_modal", * "target": "restore_streak" * } * ``` + * * @see HyperskillAnalyticEvent */ class StreakRecoveryModalClickedRestoreStreakHyperskillAnalyticEvent : HyperskillAnalyticEvent( - HyperskillAnalyticRoute.Home(), + HyperskillAnalyticRoute.None, HyperskillAnalyticAction.CLICK, HyperskillAnalyticPart.STREAK_RECOVERY_MODAL, HyperskillAnalyticTarget.RESTORE_STREAK diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalHiddenHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalHiddenHyperskillAnalyticEvent.kt index c5b9892f2e..b83ac627b4 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalHiddenHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalHiddenHyperskillAnalyticEvent.kt @@ -12,16 +12,17 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * JSON payload: * ``` * { - * "route": "/home", + * "route": "None", * "action": "hidden", * "part": "streak_recovery_modal", * "target": "close" * } * ``` + * * @see HyperskillAnalyticEvent */ class StreakRecoveryModalHiddenHyperskillAnalyticEvent : HyperskillAnalyticEvent( - HyperskillAnalyticRoute.Home(), + HyperskillAnalyticRoute.None, HyperskillAnalyticAction.HIDDEN, HyperskillAnalyticPart.STREAK_RECOVERY_MODAL, HyperskillAnalyticTarget.CLOSE diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalShownHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalShownHyperskillAnalyticEvent.kt index a1e8fea9c8..17b3395a3b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalShownHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/streak_recovery/domain/analytic/StreakRecoveryModalShownHyperskillAnalyticEvent.kt @@ -12,16 +12,17 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * JSON payload: * ``` * { - * "route": "/home", + * "route": "None", * "action": "shown", * "part": "modal", * "target": "streak_recovery_modal" * } * ``` + * * @see HyperskillAnalyticEvent */ class StreakRecoveryModalShownHyperskillAnalyticEvent : HyperskillAnalyticEvent( - HyperskillAnalyticRoute.Home(), + HyperskillAnalyticRoute.None, HyperskillAnalyticAction.SHOWN, HyperskillAnalyticPart.MODAL, HyperskillAnalyticTarget.STREAK_RECOVERY_MODAL diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenComponentImpl.kt index 4bd3097f8c..b2a654a416 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenComponentImpl.kt @@ -7,9 +7,10 @@ import org.hyperskill.app.problems_limit.domain.model.ProblemsLimitScreen import org.hyperskill.app.problems_limit.injection.ProblemsLimitComponent import org.hyperskill.app.study_plan.screen.presentation.StudyPlanScreenFeature import org.hyperskill.app.study_plan.widget.injection.StudyPlanWidgetComponent +import org.hyperskill.app.users_questionnaire.widget.injection.UsersQuestionnaireWidgetComponent import ru.nobird.app.presentation.redux.feature.Feature -class StudyPlanScreenComponentImpl(private val appGraph: AppGraph) : StudyPlanScreenComponent { +internal class StudyPlanScreenComponentImpl(private val appGraph: AppGraph) : StudyPlanScreenComponent { private val toolbarComponent: GamificationToolbarComponent = appGraph.buildGamificationToolbarComponent(GamificationToolbarScreen.STUDY_PLAN) @@ -17,6 +18,9 @@ class StudyPlanScreenComponentImpl(private val appGraph: AppGraph) : StudyPlanSc private val problemsLimitComponent: ProblemsLimitComponent = appGraph.buildProblemsLimitComponent(ProblemsLimitScreen.STUDY_PLAN) + private val usersQuestionnaireWidgetComponent: UsersQuestionnaireWidgetComponent = + appGraph.buildUsersQuestionnaireWidgetComponent() + private val studyPlanWidgetComponent: StudyPlanWidgetComponent = appGraph.buildStudyPlanWidgetComponent() @@ -29,6 +33,9 @@ class StudyPlanScreenComponentImpl(private val appGraph: AppGraph) : StudyPlanSc problemsLimitReducer = problemsLimitComponent.problemsLimitReducer, problemsLimitActionDispatcher = problemsLimitComponent.problemsLimitActionDispatcher, problemsLimitViewStateMapper = problemsLimitComponent.problemsLimitViewStateMapper, + usersQuestionnaireWidgetReducer = usersQuestionnaireWidgetComponent.usersQuestionnaireWidgetReducer, + usersQuestionnaireWidgetActionDispatcher = usersQuestionnaireWidgetComponent + .usersQuestionnaireWidgetActionDispatcher, studyPlanWidgetReducer = studyPlanWidgetComponent.studyPlanWidgetReducer, studyPlanWidgetDispatcher = studyPlanWidgetComponent.studyPlanWidgetDispatcher, studyPlanWidgetViewStateMapper = studyPlanWidgetComponent.studyPlanWidgetViewStateMapper, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenFeatureBuilder.kt index ddba9eb893..47c05e2446 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/injection/StudyPlanScreenFeatureBuilder.kt @@ -22,6 +22,9 @@ import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetActionDi import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetReducer import org.hyperskill.app.study_plan.widget.view.mapper.StudyPlanWidgetViewStateMapper +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetActionDispatcher +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetReducer import ru.nobird.app.core.model.safeCast import ru.nobird.app.presentation.redux.dispatcher.transform import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher @@ -37,6 +40,8 @@ internal object StudyPlanScreenFeatureBuilder { toolbarActionDispatcher: GamificationToolbarActionDispatcher, problemsLimitReducer: ProblemsLimitReducer, problemsLimitActionDispatcher: ProblemsLimitActionDispatcher, + usersQuestionnaireWidgetReducer: UsersQuestionnaireWidgetReducer, + usersQuestionnaireWidgetActionDispatcher: UsersQuestionnaireWidgetActionDispatcher, studyPlanWidgetReducer: StudyPlanWidgetReducer, studyPlanWidgetDispatcher: StudyPlanWidgetActionDispatcher, problemsLimitViewStateMapper: ProblemsLimitViewStateMapper, @@ -48,6 +53,7 @@ internal object StudyPlanScreenFeatureBuilder { val studyPlanScreenReducer = StudyPlanScreenReducer( toolbarReducer = toolbarReducer, problemsLimitReducer = problemsLimitReducer, + usersQuestionnaireWidgetReducer = usersQuestionnaireWidgetReducer, studyPlanWidgetReducer = studyPlanWidgetReducer ).wrapWithLogger(buildVariant, logger, LOG_TAG) val studyPlanScreenActionDispatcher = StudyPlanScreenActionDispatcher( @@ -64,6 +70,7 @@ internal object StudyPlanScreenFeatureBuilder { StudyPlanScreenFeature.State( toolbarState = GamificationToolbarFeature.State.Idle, problemsLimitState = ProblemsLimitFeature.State.Idle, + usersQuestionnaireWidgetState = UsersQuestionnaireWidgetFeature.State.Idle, studyPlanWidgetState = StudyPlanWidgetFeature.State() ), reducer = studyPlanScreenReducer @@ -86,6 +93,14 @@ internal object StudyPlanScreenFeatureBuilder { transformMessage = StudyPlanScreenFeature.Message::ProblemsLimitMessage ) ) + .wrapWithActionDispatcher( + usersQuestionnaireWidgetActionDispatcher.transform( + transformAction = { + it.safeCast()?.action + }, + transformMessage = StudyPlanScreenFeature.Message::UsersQuestionnaireWidgetMessage + ) + ) .wrapWithActionDispatcher( studyPlanWidgetDispatcher.transform( transformAction = { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenFeature.kt index 59360e265c..31f672844b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenFeature.kt @@ -7,12 +7,13 @@ import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature import org.hyperskill.app.problems_limit.presentation.ProblemsLimitFeature.isRefreshing import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature import org.hyperskill.app.study_plan.widget.view.model.StudyPlanWidgetViewState +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature object StudyPlanScreenFeature { - internal data class State( val toolbarState: GamificationToolbarFeature.State, val problemsLimitState: ProblemsLimitFeature.State, + val usersQuestionnaireWidgetState: UsersQuestionnaireWidgetFeature.State, val studyPlanWidgetState: StudyPlanWidgetFeature.State ) { val isRefreshing: Boolean @@ -25,12 +26,12 @@ object StudyPlanScreenFeature { val trackTitle: String?, val toolbarViewState: GamificationToolbarFeature.ViewState, val problemsLimitViewState: ProblemsLimitFeature.ViewState, + val usersQuestionnaireWidgetState: UsersQuestionnaireWidgetFeature.State, val studyPlanWidgetViewState: StudyPlanWidgetViewState, val isRefreshing: Boolean ) sealed interface Message { - object Initialize : Message object RetryContentLoading : Message @@ -49,6 +50,10 @@ object StudyPlanScreenFeature { val message: ProblemsLimitFeature.Message ) : Message + data class UsersQuestionnaireWidgetMessage( + val message: UsersQuestionnaireWidgetFeature.Message + ) : Message + data class StudyPlanWidgetMessage( val message: StudyPlanWidgetFeature.Message ) : Message @@ -59,9 +64,15 @@ object StudyPlanScreenFeature { data class GamificationToolbarViewAction( val viewAction: GamificationToolbarFeature.Action.ViewAction ) : ViewAction + data class ProblemsLimitViewAction( val viewAction: ProblemsLimitFeature.Action.ViewAction ) : ViewAction + + data class UsersQuestionnaireWidgetViewAction( + val viewAction: UsersQuestionnaireWidgetFeature.Action.ViewAction + ) : ViewAction + data class StudyPlanWidgetViewAction( val viewAction: StudyPlanWidgetFeature.Action.ViewAction ) : ViewAction @@ -69,19 +80,22 @@ object StudyPlanScreenFeature { } internal sealed interface InternalAction : Action { - data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction data class GamificationToolbarAction( val action: GamificationToolbarFeature.Action ) : InternalAction - data class StudyPlanWidgetAction( - val action: StudyPlanWidgetFeature.Action - ) : InternalAction - data class ProblemsLimitAction( val action: ProblemsLimitFeature.Action ) : InternalAction + + data class UsersQuestionnaireWidgetAction( + val action: UsersQuestionnaireWidgetFeature.Action + ) : InternalAction + + data class StudyPlanWidgetAction( + val action: StudyPlanWidgetFeature.Action + ) : InternalAction } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenReducer.kt index 1480fa0e6d..1101a985df 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/presentation/StudyPlanScreenReducer.kt @@ -9,6 +9,8 @@ import org.hyperskill.app.study_plan.domain.analytic.StudyPlanClickedRetryConten import org.hyperskill.app.study_plan.domain.analytic.StudyPlanViewedHyperskillAnalyticEvent import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetReducer +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetReducer import ru.nobird.app.presentation.redux.reducer.StateReducer internal typealias StudyPlanScreenReducerResult = Pair> @@ -16,6 +18,7 @@ internal typealias StudyPlanScreenReducerResult = Pair { override fun reduce( @@ -27,26 +30,8 @@ internal class StudyPlanScreenReducer( initializeFeatures(state) is StudyPlanScreenFeature.Message.RetryContentLoading -> initializeFeatures(state, retryContentLoadingClicked = true) - is StudyPlanScreenFeature.Message.PullToRefresh -> { - val (widgetState, widgetActions) = reduceStudyPlanWidgetMessage( - state.studyPlanWidgetState, - StudyPlanWidgetFeature.Message.PullToRefresh - ) - - val (toolbarState, toolbarActions) = reduceToolbarMessage( - state.toolbarState, - GamificationToolbarFeature.InternalMessage.PullToRefresh - ) - - state.copy( - studyPlanWidgetState = widgetState, - toolbarState = toolbarState - ) to widgetActions + toolbarActions + setOf( - StudyPlanScreenFeature.InternalAction.LogAnalyticEvent( - StudyPlanClickedPullToRefreshHyperskillAnalyticEvent() - ) - ) - } + is StudyPlanScreenFeature.Message.PullToRefresh -> + handlePullToRefreshMessage(state) is StudyPlanScreenFeature.Message.ScreenBecomesActive -> { val (widgetState, widgetActions) = reduceStudyPlanWidgetMessage( state.studyPlanWidgetState, @@ -64,6 +49,13 @@ internal class StudyPlanScreenReducer( reduceProblemsLimitMessage(state.problemsLimitState, message.message) state.copy(problemsLimitState = problemsLimitState) to problemsLimitActions } + is StudyPlanScreenFeature.Message.UsersQuestionnaireWidgetMessage -> { + val (usersQuestionnaireWidgetState, usersQuestionnaireWidgetActions) = + reduceUsersQuestionnaireWidgetMessage(state.usersQuestionnaireWidgetState, message.message) + state.copy( + usersQuestionnaireWidgetState = usersQuestionnaireWidgetState + ) to usersQuestionnaireWidgetActions + } is StudyPlanScreenFeature.Message.StudyPlanWidgetMessage -> { val (widgetState, widgetActions) = reduceStudyPlanWidgetMessage(state.studyPlanWidgetState, message.message) @@ -90,7 +82,12 @@ internal class StudyPlanScreenReducer( val (problemsLimitState, problemsLimitActions) = reduceProblemsLimitMessage( state.problemsLimitState, - ProblemsLimitFeature.Message.Initialize(forceUpdate = retryContentLoadingClicked) + ProblemsLimitFeature.InternalMessage.Initialize(forceUpdate = retryContentLoadingClicked) + ) + val (usersQuestionnaireWidgetState, usersQuestionnaireWidgetActions) = + reduceUsersQuestionnaireWidgetMessage( + state.usersQuestionnaireWidgetState, + UsersQuestionnaireWidgetFeature.InternalMessage.Initialize ) val (studyPlanState, studyPlanActions) = reduceStudyPlanWidgetMessage( @@ -108,11 +105,46 @@ internal class StudyPlanScreenReducer( emptySet() } + val actions = toolbarActions + + problemsLimitActions + + usersQuestionnaireWidgetActions + + studyPlanActions + + analyticActions + return state.copy( toolbarState = toolbarState, problemsLimitState = problemsLimitState, + usersQuestionnaireWidgetState = usersQuestionnaireWidgetState, studyPlanWidgetState = studyPlanState - ) to (toolbarActions + problemsLimitActions + studyPlanActions + analyticActions) + ) to actions + } + + private fun handlePullToRefreshMessage( + state: StudyPlanScreenFeature.State + ): StudyPlanScreenReducerResult { + val (toolbarState, toolbarActions) = reduceToolbarMessage( + state.toolbarState, + GamificationToolbarFeature.InternalMessage.PullToRefresh + ) + val (problemsLimitState, problemsLimitActions) = + reduceProblemsLimitMessage( + state.problemsLimitState, + ProblemsLimitFeature.InternalMessage.PullToRefresh + ) + val (studyPlanWidgetState, studyPlanWidgetActions) = reduceStudyPlanWidgetMessage( + state.studyPlanWidgetState, + StudyPlanWidgetFeature.Message.PullToRefresh + ) + + return state.copy( + toolbarState = toolbarState, + problemsLimitState = problemsLimitState, + studyPlanWidgetState = studyPlanWidgetState + ) to toolbarActions + studyPlanWidgetActions + problemsLimitActions + setOf( + StudyPlanScreenFeature.InternalAction.LogAnalyticEvent( + StudyPlanClickedPullToRefreshHyperskillAnalyticEvent() + ) + ) } private fun reduceToolbarMessage( @@ -153,6 +185,26 @@ internal class StudyPlanScreenReducer( return problemsLimitState to actions } + private fun reduceUsersQuestionnaireWidgetMessage( + state: UsersQuestionnaireWidgetFeature.State, + message: UsersQuestionnaireWidgetFeature.Message + ): Pair> { + val (usersQuestionnaireWidgetState, usersQuestionnaireWidgetActions) = + usersQuestionnaireWidgetReducer.reduce(state, message) + + val actions = usersQuestionnaireWidgetActions + .map { + if (it is UsersQuestionnaireWidgetFeature.Action.ViewAction) { + StudyPlanScreenFeature.Action.ViewAction.UsersQuestionnaireWidgetViewAction(it) + } else { + StudyPlanScreenFeature.InternalAction.UsersQuestionnaireWidgetAction(it) + } + } + .toSet() + + return usersQuestionnaireWidgetState to actions + } + private fun reduceStudyPlanWidgetMessage( state: StudyPlanWidgetFeature.State, message: StudyPlanWidgetFeature.Message diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/view/StudyPlanScreenViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/view/StudyPlanScreenViewStateMapper.kt index 7726fa2751..81162a39dc 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/view/StudyPlanScreenViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/screen/view/StudyPlanScreenViewStateMapper.kt @@ -17,6 +17,7 @@ internal class StudyPlanScreenViewStateMapper( trackTitle = getTrackTitle(state), toolbarViewState = GamificationToolbarViewStateMapper.map(state.toolbarState), problemsLimitViewState = problemsLimitViewStateMapper.mapState(state.problemsLimitState), + usersQuestionnaireWidgetState = state.usersQuestionnaireWidgetState, studyPlanWidgetViewState = studyPlanWidgetViewStateMapper.map(state.studyPlanWidgetState), isRefreshing = state.isRefreshing ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StateExtentions.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StateExtentions.kt index 54c16bd4d3..99caf42b74 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StateExtentions.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/study_plan/widget/presentation/StateExtentions.kt @@ -10,7 +10,7 @@ import org.hyperskill.app.study_plan.domain.model.StudyPlanSectionType * * `studyPlanSections` map preserves the entry iteration order, so we can use the first element as the current section. * - * @see StudyPlanWidgetReducer.handleSectionsFetchSuccess + * @see StudyPlanWidgetReducer.handleLearningActivitiesWithSectionsFetchSuccess */ internal fun StudyPlanWidgetFeature.State.getCurrentSection(): StudyPlanSection? = studyPlanSections.values.firstOrNull()?.studyPlanSection diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/cache/CurrentSubscriptionStateHolderImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/cache/CurrentSubscriptionStateHolderImpl.kt new file mode 100644 index 0000000000..5ecc19dfae --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/cache/CurrentSubscriptionStateHolderImpl.kt @@ -0,0 +1,33 @@ +package org.hyperskill.app.subscriptions.cache + +import com.russhwolf.settings.Settings +import com.russhwolf.settings.contains +import kotlinx.serialization.json.Json +import org.hyperskill.app.subscriptions.data.source.CurrentSubscriptionStateHolder +import org.hyperskill.app.subscriptions.domain.model.Subscription + +internal class CurrentSubscriptionStateHolderImpl( + private val json: Json, + private val settings: Settings +) : CurrentSubscriptionStateHolder { + override suspend fun getState(): Subscription? = + if (settings.contains(SubscriptionCacheKeys.CURRENT_SUBSCRIPTION)) { + json.decodeFromString( + Subscription.serializer(), + settings.getString(SubscriptionCacheKeys.CURRENT_SUBSCRIPTION) + ) + } else { + null + } + + override suspend fun setState(newState: Subscription) { + settings.putString( + SubscriptionCacheKeys.CURRENT_SUBSCRIPTION, + json.encodeToString(Subscription.serializer(), newState) + ) + } + + override fun resetState() { + settings.remove(SubscriptionCacheKeys.CURRENT_SUBSCRIPTION) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/cache/SubscriptionCacheKeys.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/cache/SubscriptionCacheKeys.kt new file mode 100644 index 0000000000..e1a57b4cef --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/cache/SubscriptionCacheKeys.kt @@ -0,0 +1,5 @@ +package org.hyperskill.app.subscriptions.cache + +internal object SubscriptionCacheKeys { + const val CURRENT_SUBSCRIPTION = "current_subscription" +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/repository/CurrentSubscriptionStateRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/repository/CurrentSubscriptionStateRepositoryImpl.kt index d1370eb2c5..026de22c60 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/repository/CurrentSubscriptionStateRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/repository/CurrentSubscriptionStateRepositoryImpl.kt @@ -1,12 +1,14 @@ package org.hyperskill.app.subscriptions.data.repository import org.hyperskill.app.core.data.repository.BaseStateRepository +import org.hyperskill.app.subscriptions.data.source.CurrentSubscriptionStateHolder import org.hyperskill.app.subscriptions.data.source.SubscriptionsRemoteDataSource import org.hyperskill.app.subscriptions.domain.model.Subscription import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository -class CurrentSubscriptionStateRepositoryImpl( - private val subscriptionsRemoteDataSource: SubscriptionsRemoteDataSource +internal class CurrentSubscriptionStateRepositoryImpl( + private val subscriptionsRemoteDataSource: SubscriptionsRemoteDataSource, + override val stateHolder: CurrentSubscriptionStateHolder ) : CurrentSubscriptionStateRepository, BaseStateRepository() { override suspend fun loadState(): Result = subscriptionsRemoteDataSource.getCurrentSubscription() diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/repository/SubscriptionsRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/repository/SubscriptionsRepositoryImpl.kt new file mode 100644 index 0000000000..7ce4f51a20 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/repository/SubscriptionsRepositoryImpl.kt @@ -0,0 +1,12 @@ +package org.hyperskill.app.subscriptions.data.repository + +import org.hyperskill.app.subscriptions.data.source.SubscriptionsRemoteDataSource +import org.hyperskill.app.subscriptions.domain.model.Subscription +import org.hyperskill.app.subscriptions.domain.repository.SubscriptionsRepository + +internal class SubscriptionsRepositoryImpl( + private val subscriptionsRemoteDataSource: SubscriptionsRemoteDataSource +) : SubscriptionsRepository { + override suspend fun syncSubscription(): Result = + subscriptionsRemoteDataSource.syncSubscription() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/source/CurrentSubscriptionStateHolder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/source/CurrentSubscriptionStateHolder.kt new file mode 100644 index 0000000000..8b0c7fd6fe --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/source/CurrentSubscriptionStateHolder.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.subscriptions.data.source + +import org.hyperskill.app.core.domain.repository.StateHolder +import org.hyperskill.app.subscriptions.domain.model.Subscription + +internal interface CurrentSubscriptionStateHolder : StateHolder \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/source/SubscriptionsRemoteDataSource.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/source/SubscriptionsRemoteDataSource.kt index efb33563fd..ef81b9bc07 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/source/SubscriptionsRemoteDataSource.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/data/source/SubscriptionsRemoteDataSource.kt @@ -4,4 +4,6 @@ import org.hyperskill.app.subscriptions.domain.model.Subscription interface SubscriptionsRemoteDataSource { suspend fun getCurrentSubscription(): Result + + suspend fun syncSubscription(): Result } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/interactor/SubscriptionsInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/interactor/SubscriptionsInteractor.kt new file mode 100644 index 0000000000..d5cfe32e30 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/interactor/SubscriptionsInteractor.kt @@ -0,0 +1,151 @@ +package org.hyperskill.app.subscriptions.domain.interactor + +import co.touchlab.kermit.Logger +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import org.hyperskill.app.auth.domain.interactor.AuthInteractor +import org.hyperskill.app.core.domain.repository.updateState +import org.hyperskill.app.profile.domain.model.isFreemiumIncreaseLimitsForFirstStepCompletionEnabled +import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository +import org.hyperskill.app.profile.domain.repository.isFreemiumWrongSubmissionChargeLimitsEnabled +import org.hyperskill.app.subscriptions.domain.model.FreemiumChargeLimitsStrategy +import org.hyperskill.app.subscriptions.domain.model.Subscription +import org.hyperskill.app.subscriptions.domain.model.SubscriptionType +import org.hyperskill.app.subscriptions.domain.model.isActive +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository +import org.hyperskill.app.subscriptions.domain.repository.areProblemsLimited + +class SubscriptionsInteractor( + private val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository, + private val currentProfileStateRepository: CurrentProfileStateRepository, + private val authInteractor: AuthInteractor, + logger: Logger +) { + companion object { + private const val LOG_TAG = "SubscriptionsInteractor" + private const val SOLVING_FIRST_STEP_ADDITIONAL_LIMIT_VALUE = 10 + } + + private val logger: Logger = logger.withTag(LOG_TAG) + + private var refreshMobileOnlySubscriptionJob: Job? = null + + // Problems limits + + suspend fun getCurrentSubscription(): Result = + currentSubscriptionStateRepository.getState(forceUpdate = false) + + suspend fun chargeProblemsLimits(chargeStrategy: FreemiumChargeLimitsStrategy) { + if (currentSubscriptionStateRepository.areProblemsLimited()) { + when (chargeStrategy) { + FreemiumChargeLimitsStrategy.AFTER_WRONG_SUBMISSION -> chargeLimitsAfterWrongSubmission() + FreemiumChargeLimitsStrategy.AFTER_CORRECT_SUBMISSION -> chargeLimitsAfterCorrectSubmission() + } + } + } + + private suspend fun chargeLimitsAfterWrongSubmission() { + if (currentProfileStateRepository.isFreemiumWrongSubmissionChargeLimitsEnabled()) { + decreaseStepsLimitLeft() + } + } + + private suspend fun decreaseStepsLimitLeft() { + currentSubscriptionStateRepository.updateState { subscription -> + subscription.copy(stepsLimitLeft = subscription.stepsLimitLeft?.dec()) + } + } + + private suspend fun chargeLimitsAfterCorrectSubmission() { + increaseLimitsForFirstStepCompletionIfNeeded() + + if (!currentProfileStateRepository.isFreemiumWrongSubmissionChargeLimitsEnabled()) { + decreaseStepsLimitLeft() + } + } + + private suspend fun increaseLimitsForFirstStepCompletionIfNeeded() { + val currentProfile = currentProfileStateRepository + .getState(forceUpdate = false) + .getOrElse { return } + + if (currentProfile.features.isFreemiumIncreaseLimitsForFirstStepCompletionEnabled && + currentProfile.gamification.passedProblems == 0 + ) { + currentSubscriptionStateRepository.updateState { + it.copy( + stepsLimitTotal = it.stepsLimitTotal?.plus(SOLVING_FIRST_STEP_ADDITIONAL_LIMIT_VALUE), + stepsLimitLeft = it.stepsLimitLeft?.plus(SOLVING_FIRST_STEP_ADDITIONAL_LIMIT_VALUE) + ) + } + currentProfileStateRepository.updateState { + it.copy(gamification = it.gamification.copy(passedProblems = it.gamification.passedProblems + 1)) + } + } + } + + // Refresh mobile only subscription + + suspend fun refreshSubscriptionOnExpirationIfNeeded(subscription: Subscription) { + cancelSubscriptionRefresh() + + val isActiveMobileOnlySubscription = + subscription.type == SubscriptionType.MOBILE_ONLY && subscription.isActive + + if (isActiveMobileOnlySubscription && subscription.validTill != null) { + coroutineScope { + refreshMobileOnlySubscriptionJob = launch { + refreshMobileOnlySubscriptionOnExpiration(subscription.validTill) + } + refreshMobileOnlySubscriptionJob?.invokeOnCompletion { + refreshMobileOnlySubscriptionJob = null + } + } + } + } + + private suspend fun refreshMobileOnlySubscriptionOnExpiration( + subscriptionValidTill: Instant + ) { + val nowByUTC = Clock.System.now() + .toLocalDateTime(TimeZone.UTC) + .toInstant(TimeZone.UTC) + + // ALTAPPS-1155: Add one minute to wait until the subscription is synced on the backend + val delayDuration = subscriptionValidTill - nowByUTC + 1.toDuration(DurationUnit.MINUTES) + logger.d { "Wait ${delayDuration.inWholeSeconds} seconds for subscription expiration to refresh it" } + + delay(delayDuration) + if (isUserAuthorized()) { + currentSubscriptionStateRepository + .getState(forceUpdate = true) + .onSuccess { freshSubscription -> + logger.d { + """Subscription successfully refreshed. + Type=${freshSubscription.type},status=${freshSubscription.status} + """.trimMargin() + } + } + .onFailure { e -> + logger.e(e) { "Failed to refresh subscription" } + } + } + } + + fun cancelSubscriptionRefresh() { + refreshMobileOnlySubscriptionJob?.cancel() + refreshMobileOnlySubscriptionJob = null + } + + private suspend fun isUserAuthorized(): Boolean = + authInteractor.isAuthorized().getOrDefault(false) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/FreemiumChargeLimitsStrategy.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/FreemiumChargeLimitsStrategy.kt new file mode 100644 index 0000000000..6dec69d777 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/FreemiumChargeLimitsStrategy.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.subscriptions.domain.model + +enum class FreemiumChargeLimitsStrategy { + AFTER_WRONG_SUBMISSION, + AFTER_CORRECT_SUBMISSION +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/Subscription.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/Subscription.kt index d36a64e300..d46e20ea87 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/Subscription.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/Subscription.kt @@ -1,23 +1,66 @@ package org.hyperskill.app.subscriptions.domain.model +import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import org.hyperskill.app.subscriptions.cache.CurrentSubscriptionStateHolderImpl /** + * Represents a user subscription. + * + * Warning! + * This model is stored in the cache. + * Adding new field or modifying old ones, + * check that all fields will be deserialized from cache without an error. + * All the new optional fields must have default values. + * @see [CurrentSubscriptionStateHolderImpl] + * * If the [stepsLimitResetTime] is null, then the user doesn't have submissions yet */ @Serializable data class Subscription( @SerialName("type") val type: SubscriptionType = SubscriptionType.UNKNOWN, + val status: SubscriptionStatus = SubscriptionStatus.ACTIVE, @SerialName("steps_limit_total") - val stepsLimitTotal: Int?, + val stepsLimitTotal: Int? = null, @SerialName("steps_limit_left") - val stepsLimitLeft: Int?, + val stepsLimitLeft: Int? = null, @SerialName("steps_limit_reset_time") - val stepsLimitResetTime: Instant? + val stepsLimitResetTime: Instant? = null, + @SerialName("valid_till") + val validTill: Instant? = null ) -val Subscription.isFreemium: Boolean - get() = type == SubscriptionType.FREEMIUM \ No newline at end of file +internal val Subscription.areProblemsLimited: Boolean + get() = when (type) { + SubscriptionType.MOBILE_ONLY -> type.areProblemsLimited || status != SubscriptionStatus.ACTIVE + else -> type.areProblemsLimited + } + +internal val Subscription.isProblemsLimitReached: Boolean + get() = areProblemsLimited && stepsLimitLeft == 0 + +internal val Subscription.isFreemium: Boolean + get() = type == SubscriptionType.FREEMIUM || + type == SubscriptionType.MOBILE_ONLY && status != SubscriptionStatus.ACTIVE + +internal val Subscription.isActive: Boolean + get() = status == SubscriptionStatus.ACTIVE + +internal val Subscription.isExpired: Boolean + get() = status == SubscriptionStatus.EXPIRED + +internal fun Subscription.isValidTillPassed(): Boolean = + if (validTill != null) { + val nowByUTC = Clock.System.now() + .toLocalDateTime(TimeZone.UTC) + .toInstant(TimeZone.UTC) + validTill < nowByUTC + } else { + false + } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/SubscriptionStatus.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/SubscriptionStatus.kt new file mode 100644 index 0000000000..265bdb4730 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/SubscriptionStatus.kt @@ -0,0 +1,16 @@ +package org.hyperskill.app.subscriptions.domain.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class SubscriptionStatus { + @SerialName("pending") + PENDING, + @SerialName("not_active") + NOT_ACTIVE, + @SerialName("active") + ACTIVE, + @SerialName("expired") + EXPIRED +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/SubscriptionType.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/SubscriptionType.kt index 69190768f6..28d364d28e 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/SubscriptionType.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/model/SubscriptionType.kt @@ -4,15 +4,21 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -enum class SubscriptionType { +enum class SubscriptionType( + val isProjectSelectionEnabled: Boolean = false, + val isProjectInfoAvailable: Boolean = true, + val isCertificateAvailable: Boolean = true, + val areHintsLimited: Boolean = false, + val areProblemsLimited: Boolean = false +) { @SerialName("personal") - PERSONAL, + PERSONAL(isProjectSelectionEnabled = true), @SerialName("commercial") COMMERCIAL, @SerialName("team member") TEAM_MEMBER, @SerialName("trial") - TRIAL, + TRIAL(isProjectSelectionEnabled = true), @SerialName("content trial") CONTENT_TRIAL, @SerialName("organization trial") @@ -27,10 +33,23 @@ enum class SubscriptionType { JETBRAINS_TEAM, @SerialName("free") FREE, + @SerialName("freemium") - FREEMIUM, + FREEMIUM( + isCertificateAvailable = false, + isProjectInfoAvailable = false, + areHintsLimited = true, + areProblemsLimited = true + ), + @SerialName("mobile only") + MOBILE_ONLY( + isCertificateAvailable = false, + isProjectInfoAvailable = false, + areHintsLimited = true + ), + @SerialName("premium") - PREMIUM, + PREMIUM(isProjectSelectionEnabled = true), @SerialName("unknown") UNKNOWN diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/repository/CurrentSubscriptionStateRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/repository/CurrentSubscriptionStateRepository.kt index 2a95409c46..9c1ef376ca 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/repository/CurrentSubscriptionStateRepository.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/repository/CurrentSubscriptionStateRepository.kt @@ -2,5 +2,11 @@ package org.hyperskill.app.subscriptions.domain.repository import org.hyperskill.app.core.domain.repository.StateRepository import org.hyperskill.app.subscriptions.domain.model.Subscription +import org.hyperskill.app.subscriptions.domain.model.areProblemsLimited -interface CurrentSubscriptionStateRepository : StateRepository \ No newline at end of file +interface CurrentSubscriptionStateRepository : StateRepository + +internal suspend fun CurrentSubscriptionStateRepository.areProblemsLimited(): Boolean = + getState(forceUpdate = false) + .map { it.areProblemsLimited } + .getOrDefault(defaultValue = false) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/repository/SubscriptionsRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/repository/SubscriptionsRepository.kt new file mode 100644 index 0000000000..de53d163c3 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/domain/repository/SubscriptionsRepository.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.subscriptions.domain.repository + +import org.hyperskill.app.subscriptions.domain.model.Subscription + +interface SubscriptionsRepository { + suspend fun syncSubscription(): Result +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/injection/SubscriptionsDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/injection/SubscriptionsDataComponent.kt new file mode 100644 index 0000000000..ee6c09db8e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/injection/SubscriptionsDataComponent.kt @@ -0,0 +1,9 @@ +package org.hyperskill.app.subscriptions.injection + +import org.hyperskill.app.subscriptions.domain.interactor.SubscriptionsInteractor +import org.hyperskill.app.subscriptions.domain.repository.SubscriptionsRepository + +interface SubscriptionsDataComponent { + val subscriptionsRepository: SubscriptionsRepository + val subscriptionsInteractor: SubscriptionsInteractor +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/injection/SubscriptionsDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/injection/SubscriptionsDataComponentImpl.kt new file mode 100644 index 0000000000..467471ae67 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/injection/SubscriptionsDataComponentImpl.kt @@ -0,0 +1,43 @@ +package org.hyperskill.app.subscriptions.injection + +import co.touchlab.kermit.Logger +import org.hyperskill.app.auth.domain.interactor.AuthInteractor +import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository +import org.hyperskill.app.subscriptions.data.repository.SubscriptionsRepositoryImpl +import org.hyperskill.app.subscriptions.domain.interactor.SubscriptionsInteractor +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository +import org.hyperskill.app.subscriptions.domain.repository.SubscriptionsRepository +import org.hyperskill.app.subscriptions.remote.SubscriptionsRemoteDataSourceImpl + +class SubscriptionsDataComponentImpl( + appGraph: AppGraph +) : SubscriptionsDataComponent { + + private val subscriptionsRemoteDataSource = + SubscriptionsRemoteDataSourceImpl(appGraph.networkComponent.authorizedHttpClient) + + private val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository = + appGraph.stateRepositoriesComponent.currentSubscriptionStateRepository + + private val currentProfileStateRepository: CurrentProfileStateRepository = + appGraph.profileDataComponent.currentProfileStateRepository + + private val authInteractor: AuthInteractor = + appGraph.authComponent.authInteractor + + private val logger: Logger = + appGraph.loggerComponent.logger + + override val subscriptionsRepository: SubscriptionsRepository = + SubscriptionsRepositoryImpl(subscriptionsRemoteDataSource) + + override val subscriptionsInteractor: SubscriptionsInteractor by lazy { + SubscriptionsInteractor( + currentSubscriptionStateRepository = currentSubscriptionStateRepository, + currentProfileStateRepository = currentProfileStateRepository, + authInteractor = authInteractor, + logger = logger + ) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/remote/SubscriptionsRemoteDataSourceImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/remote/SubscriptionsRemoteDataSourceImpl.kt index 3bc56bfec9..ee4215f4b7 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/remote/SubscriptionsRemoteDataSourceImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/subscriptions/remote/SubscriptionsRemoteDataSourceImpl.kt @@ -3,6 +3,7 @@ package org.hyperskill.app.subscriptions.remote import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get +import io.ktor.client.request.post import io.ktor.http.ContentType import io.ktor.http.contentType import org.hyperskill.app.subscriptions.data.source.SubscriptionsRemoteDataSource @@ -13,9 +14,16 @@ class SubscriptionsRemoteDataSourceImpl( private val httpClient: HttpClient ) : SubscriptionsRemoteDataSource { override suspend fun getCurrentSubscription(): Result = - kotlin.runCatching { + runCatching { httpClient.get("/api/subscriptions/current") { contentType(ContentType.Application.Json) }.body().subscriptions.first() } + + override suspend fun syncSubscription(): Result = + runCatching { + httpClient.post("/api/subscription-infos/refresh-mobile") { + contentType(ContentType.Application.Json) + }.body().subscriptions.first() + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/injection/TopicsRepetitionsComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/injection/TopicsRepetitionsComponentImpl.kt index 1e28630721..e306c3d25c 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/injection/TopicsRepetitionsComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/injection/TopicsRepetitionsComponentImpl.kt @@ -7,7 +7,7 @@ import org.hyperskill.app.topics_repetitions.presentation.TopicsRepetitionsFeatu import org.hyperskill.app.topics_repetitions.view.mapper.TopicsRepetitionsViewDataMapper import ru.nobird.app.presentation.redux.feature.Feature -class TopicsRepetitionsComponentImpl(private val appGraph: AppGraph) : TopicsRepetitionsComponent { +internal class TopicsRepetitionsComponentImpl(private val appGraph: AppGraph) : TopicsRepetitionsComponent { override val topicsRepetitionsFeature: Feature get() = TopicsRepetitionsFeatureBuilder.build( appGraph.buildTopicsRepetitionsDataComponent().topicsRepetitionsInteractor, @@ -15,7 +15,7 @@ class TopicsRepetitionsComponentImpl(private val appGraph: AppGraph) : TopicsRep appGraph.analyticComponent.analyticInteractor, appGraph.sentryComponent.sentryInteractor, appGraph.topicsRepetitionsFlowDataComponent.topicRepeatedFlow, - appGraph.submissionDataComponent.submissionRepository, + appGraph.stepCompletionFlowDataComponent.stepCompletedFlow, appGraph.loggerComponent.logger, appGraph.commonComponent.buildKonfig.buildVariant ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/injection/TopicsRepetitionsDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/injection/TopicsRepetitionsDataComponentImpl.kt index fd8a93d16e..6835072f46 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/injection/TopicsRepetitionsDataComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/injection/TopicsRepetitionsDataComponentImpl.kt @@ -7,7 +7,7 @@ import org.hyperskill.app.topics_repetitions.domain.interactor.TopicsRepetitions import org.hyperskill.app.topics_repetitions.domain.repository.TopicsRepetitionsRepository import org.hyperskill.app.topics_repetitions.remote.TopicsRepetitionsRemoteDataSourceImpl -class TopicsRepetitionsDataComponentImpl(appGraph: AppGraph) : TopicsRepetitionsDataComponent { +internal class TopicsRepetitionsDataComponentImpl(appGraph: AppGraph) : TopicsRepetitionsDataComponent { private val topicsRepetitionsRemoteDataSource: TopicsRepetitionsRemoteDataSource = TopicsRepetitionsRemoteDataSourceImpl(appGraph.networkComponent.authorizedHttpClient) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/injection/TopicsRepetitionsFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/injection/TopicsRepetitionsFeatureBuilder.kt index 0e5fe3bd74..ebdff52d72 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/injection/TopicsRepetitionsFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/injection/TopicsRepetitionsFeatureBuilder.kt @@ -7,7 +7,7 @@ import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.logging.presentation.wrapWithLogger import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.sentry.domain.interactor.SentryInteractor -import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow import org.hyperskill.app.topics_repetitions.domain.flow.TopicRepeatedFlow import org.hyperskill.app.topics_repetitions.domain.interactor.TopicsRepetitionsInteractor import org.hyperskill.app.topics_repetitions.presentation.TopicsRepetitionsActionDispatcher @@ -19,7 +19,7 @@ import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher import ru.nobird.app.presentation.redux.feature.Feature import ru.nobird.app.presentation.redux.feature.ReduxFeature -object TopicsRepetitionsFeatureBuilder { +internal object TopicsRepetitionsFeatureBuilder { private const val LOG_TAG = "TopicsRepetitionsFeature" fun build( @@ -28,7 +28,7 @@ object TopicsRepetitionsFeatureBuilder { analyticInteractor: AnalyticInteractor, sentryInteractor: SentryInteractor, topicRepeatedFlow: TopicRepeatedFlow, - submissionRepository: SubmissionRepository, + stepCompletedFlow: StepCompletedFlow, logger: Logger, buildVariant: BuildVariant ): Feature { @@ -41,7 +41,7 @@ object TopicsRepetitionsFeatureBuilder { analyticInteractor, sentryInteractor, topicRepeatedFlow, - submissionRepository + stepCompletedFlow ) return ReduxFeature(State.Idle, topicsRepetitionsReducer) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/presentation/TopicsRepetitionsActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/presentation/TopicsRepetitionsActionDispatcher.kt index 330a9bdf9c..7b51593578 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/presentation/TopicsRepetitionsActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/presentation/TopicsRepetitionsActionDispatcher.kt @@ -8,7 +8,7 @@ import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder -import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository +import org.hyperskill.app.step_completion.domain.flow.StepCompletedFlow import org.hyperskill.app.topics_repetitions.domain.flow.TopicRepeatedFlow import org.hyperskill.app.topics_repetitions.domain.interactor.TopicsRepetitionsInteractor import org.hyperskill.app.topics_repetitions.presentation.TopicsRepetitionsFeature.Action @@ -16,17 +16,17 @@ import org.hyperskill.app.topics_repetitions.presentation.TopicsRepetitionsFeatu import org.hyperskill.app.topics_repetitions.presentation.TopicsRepetitionsFeature.Message import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher -class TopicsRepetitionsActionDispatcher( +internal class TopicsRepetitionsActionDispatcher( config: ActionDispatcherOptions, private val topicsRepetitionsInteractor: TopicsRepetitionsInteractor, private val currentProfileStateRepository: CurrentProfileStateRepository, private val analyticInteractor: AnalyticInteractor, private val sentryInteractor: SentryInteractor, private val topicRepeatedFlow: TopicRepeatedFlow, - submissionRepository: SubmissionRepository + stepCompletedFlow: StepCompletedFlow ) : CoroutineActionDispatcher(config.createConfig()) { init { - submissionRepository.solvedStepsMutableSharedFlow + stepCompletedFlow.observe() .onEach { onNewMessage(Message.StepCompleted(it)) } .launchIn(actionScope) } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/presentation/TopicsRepetitionsReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/presentation/TopicsRepetitionsReducer.kt index 423615e443..e78f8f5b30 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/presentation/TopicsRepetitionsReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/topics_repetitions/presentation/TopicsRepetitionsReducer.kt @@ -11,7 +11,7 @@ import org.hyperskill.app.topics_repetitions.presentation.TopicsRepetitionsFeatu import org.hyperskill.app.topics_repetitions.presentation.TopicsRepetitionsFeature.State import ru.nobird.app.presentation.redux.reducer.StateReducer -class TopicsRepetitionsReducer : StateReducer { +internal class TopicsRepetitionsReducer : StateReducer { override fun reduce(state: State, message: Message): Pair> = when (message) { is Message.Initialize -> diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/domain/analytic/TrackSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/domain/analytic/TrackSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticEvent.kt similarity index 98% rename from shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/domain/analytic/TrackSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/domain/analytic/TrackSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticEvent.kt index 4e80811861..013ccbc8b7 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/domain/analytic/TrackSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/domain/analytic/TrackSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticEvent.kt @@ -21,7 +21,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * * @see HyperskillAnalyticEvent */ -class TrackSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticsEvent( +class TrackSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticEvent( trackId: Long ) : HyperskillAnalyticEvent( HyperskillAnalyticRoute.Tracks.Details(trackId), diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/presentation/TrackSelectionDetailsActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/presentation/TrackSelectionDetailsActionDispatcher.kt index a412c7322a..9bcaf61dfe 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/presentation/TrackSelectionDetailsActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/presentation/TrackSelectionDetailsActionDispatcher.kt @@ -9,9 +9,10 @@ import org.hyperskill.app.profile.domain.repository.ProfileRepository import org.hyperskill.app.providers.domain.repository.ProvidersRepository import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder +import org.hyperskill.app.sentry.domain.withTransaction import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository import org.hyperskill.app.track_selection.details.presentation.TrackSelectionDetailsFeature.Action -import org.hyperskill.app.track_selection.details.presentation.TrackSelectionDetailsFeature.FetchAdditionalInfoResult +import org.hyperskill.app.track_selection.details.presentation.TrackSelectionDetailsFeature.InternalAction import org.hyperskill.app.track_selection.details.presentation.TrackSelectionDetailsFeature.Message import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher @@ -26,20 +27,9 @@ class TrackSelectionDetailsActionDispatcher( ) : CoroutineActionDispatcher(config.createConfig()) { override suspend fun doSuspendableAction(action: Action) { when (action) { - is TrackSelectionDetailsFeature.InternalAction.FetchAdditionalInfo -> { - val transaction = - HyperskillSentryTransactionBuilder.buildTrackSelectionDetailsScreenRemoteDataLoading() - sentryInteractor.startTransaction(transaction) - val message = fetchProvidersAndSubscription(action) - .getOrElse { - sentryInteractor.finishTransaction(transaction, it) - onNewMessage(TrackSelectionDetailsFeature.TrackSelectionResult.Error) - return - } - sentryInteractor.finishTransaction(transaction) - onNewMessage(message) - } - is TrackSelectionDetailsFeature.InternalAction.SelectTrack -> { + is InternalAction.FetchAdditionalInfo -> + handleFetchAdditionalInfoAction(action, ::onNewMessage) + is InternalAction.SelectTrack -> { val currentProfile = currentProfileStateRepository .getState() .getOrElse { @@ -56,7 +46,7 @@ class TrackSelectionDetailsActionDispatcher( onNewMessage(TrackSelectionDetailsFeature.TrackSelectionResult.Success) } - is TrackSelectionDetailsFeature.InternalAction.LogAnalyticEvent -> { + is InternalAction.LogAnalyticEvent -> { analyticInteractor.logEvent(action.event) } else -> { @@ -65,31 +55,30 @@ class TrackSelectionDetailsActionDispatcher( } } - private suspend fun fetchProvidersAndSubscription( - action: TrackSelectionDetailsFeature.InternalAction.FetchAdditionalInfo - ): Result = - coroutineScope { - runCatching { + private suspend fun handleFetchAdditionalInfoAction( + action: InternalAction.FetchAdditionalInfo, + onNewMessage: (Message) -> Unit + ) { + sentryInteractor.withTransaction( + HyperskillSentryTransactionBuilder.buildTrackSelectionDetailsScreenRemoteDataLoading(), + onError = { TrackSelectionDetailsFeature.FetchAdditionalInfoResult.Error } + ) { + coroutineScope { val providersDeferred = async { - providersRepository - .getProviders(action.providerIds, action.forceLoadFromNetwork) - .getOrThrow() + providersRepository.getProviders(action.providerIds, action.forceLoadFromNetwork) } val subscriptionDeferred = async { - currentSubscriptionStateRepository - .getState(action.forceLoadFromNetwork) - .getOrThrow() + currentSubscriptionStateRepository.getState(action.forceLoadFromNetwork) } val profileDeferred = async { - currentProfileStateRepository - .getState(forceUpdate = false) - .getOrThrow() + currentProfileStateRepository.getState(forceUpdate = false) } - FetchAdditionalInfoResult.Success( - subscriptionType = subscriptionDeferred.await().type, - profile = profileDeferred.await(), - providers = providersDeferred.await() + TrackSelectionDetailsFeature.FetchAdditionalInfoResult.Success( + subscriptionType = subscriptionDeferred.await().getOrThrow().type, + profile = profileDeferred.await().getOrThrow(), + providers = providersDeferred.await().getOrThrow() ) } - } + }.let(onNewMessage) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/presentation/TrackSelectionDetailsFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/presentation/TrackSelectionDetailsFeature.kt index 9d539ce01a..1e774666f7 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/presentation/TrackSelectionDetailsFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/presentation/TrackSelectionDetailsFeature.kt @@ -22,10 +22,7 @@ object TrackSelectionDetailsFeature { val subscriptionType: SubscriptionType, val profile: Profile, val providers: List - ) : ContentState { - val isFreemiumEnabled: Boolean - get() = subscriptionType == SubscriptionType.FREEMIUM - } + ) : ContentState object NetworkError : ContentState } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/presentation/TrackSelectionDetailsReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/presentation/TrackSelectionDetailsReducer.kt index 028cb25df4..cf1464f251 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/presentation/TrackSelectionDetailsReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/presentation/TrackSelectionDetailsReducer.kt @@ -1,8 +1,7 @@ package org.hyperskill.app.track_selection.details.presentation -import org.hyperskill.app.subscriptions.domain.model.SubscriptionType import org.hyperskill.app.track.domain.model.getAllProjects -import org.hyperskill.app.track_selection.details.domain.analytic.TrackSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticsEvent +import org.hyperskill.app.track_selection.details.domain.analytic.TrackSelectionDetailsClickedRetryContentLoadingHyperskillAnalyticEvent import org.hyperskill.app.track_selection.details.domain.analytic.TrackSelectionDetailsSelectButtonClickedHyperskillAnalyticEvent import org.hyperskill.app.track_selection.details.domain.analytic.TrackSelectionDetailsViewedHyperskillAnalyticEvent import org.hyperskill.app.track_selection.details.presentation.TrackSelectionDetailsFeature.Action @@ -41,16 +40,16 @@ internal class TrackSelectionDetailsReducer : StateReducer { - val trackRelatedProjects = - state.trackWithProgress.track.getAllProjects(state.contentState.profile.isBeta) - trackRelatedProjects.isNotEmpty() - } - else -> false + return if (state.contentState.subscriptionType.isProjectSelectionEnabled) { + val trackRelatedProjects = + state.trackWithProgress.track.getAllProjects(state.contentState.profile.isBeta) + trackRelatedProjects.isNotEmpty() + } else { + false } } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/view/TrackSelectionDetailsViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/view/TrackSelectionDetailsViewStateMapper.kt index 6feed00ce2..2ada256e48 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/view/TrackSelectionDetailsViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/details/view/TrackSelectionDetailsViewStateMapper.kt @@ -32,8 +32,12 @@ internal class TrackSelectionDetailsViewStateMapper( formattedRating = formatRating(trackProgress.averageRating()), formattedTimeToComplete = formatTimeToComplete(track.secondsToComplete), formattedTopicsCount = formatTopicsCount(track.totalTopicsCount), - formattedProjectsCount = formatProjectsCount(contentState.isFreemiumEnabled, track.projects.size), - isCertificateAvailable = !contentState.isFreemiumEnabled && track.canIssueCertificate, + formattedProjectsCount = formatProjectsCount( + isProjectCountEnabled = contentState.subscriptionType.isProjectInfoAvailable, + projectsCount = track.projects.size + ), + isCertificateAvailable = contentState.subscriptionType.isCertificateAvailable && + track.canIssueCertificate, mainProvider = contentState.providers .firstOrNull { it.id == track.providerId } ?.let { mainProvider -> @@ -76,10 +80,10 @@ internal class TrackSelectionDetailsViewStateMapper( ) private fun formatProjectsCount( - isFreemiumEnabled: Boolean, + isProjectCountEnabled: Boolean, projectsCount: Int ): String? = - if (!isFreemiumEnabled) { + if (isProjectCountEnabled) { resourceProvider.getString( SharedResources.strings.track_selection_details_projects_text_template, resourceProvider.getQuantityString(SharedResources.plurals.projects, projectsCount, projectsCount) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/domain/analytic/TrackSelectionListClickedRetryContentLoadingHyperskillAnalyticsEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/domain/analytic/TrackSelectionListClickedRetryContentLoadingHyperskillAnalyticEvent.kt similarity index 89% rename from shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/domain/analytic/TrackSelectionListClickedRetryContentLoadingHyperskillAnalyticsEvent.kt rename to shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/domain/analytic/TrackSelectionListClickedRetryContentLoadingHyperskillAnalyticEvent.kt index 354681e2fe..6393e4e08d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/domain/analytic/TrackSelectionListClickedRetryContentLoadingHyperskillAnalyticsEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/domain/analytic/TrackSelectionListClickedRetryContentLoadingHyperskillAnalyticEvent.kt @@ -21,7 +21,7 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTar * * @see HyperskillAnalyticEvent */ -class TrackSelectionListClickedRetryContentLoadingHyperskillAnalyticsEvent : HyperskillAnalyticEvent( +object TrackSelectionListClickedRetryContentLoadingHyperskillAnalyticEvent : HyperskillAnalyticEvent( HyperskillAnalyticRoute.Tracks(), HyperskillAnalyticAction.CLICK, HyperskillAnalyticPart.MAIN, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/domain/analytic/TrackSelectionListViewedHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/domain/analytic/TrackSelectionListViewedHyperskillAnalyticEvent.kt index cb9185a200..c138cf8a30 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/domain/analytic/TrackSelectionListViewedHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/domain/analytic/TrackSelectionListViewedHyperskillAnalyticEvent.kt @@ -14,7 +14,10 @@ import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRou * "action": "view" * } * ``` + * * @see HyperskillAnalyticEvent */ -class TrackSelectionListViewedHyperskillAnalyticEvent : - HyperskillAnalyticEvent(HyperskillAnalyticRoute.Tracks(), HyperskillAnalyticAction.VIEW) \ No newline at end of file +object TrackSelectionListViewedHyperskillAnalyticEvent : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Tracks(), + HyperskillAnalyticAction.VIEW +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/presentation/TrackSelectionListReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/presentation/TrackSelectionListReducer.kt index 3f85e03083..1f9d6c010f 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/presentation/TrackSelectionListReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/track_selection/list/presentation/TrackSelectionListReducer.kt @@ -1,7 +1,7 @@ package org.hyperskill.app.track_selection.list.presentation import org.hyperskill.app.track.domain.model.TrackWithProgress -import org.hyperskill.app.track_selection.list.domain.analytic.TrackSelectionListClickedRetryContentLoadingHyperskillAnalyticsEvent +import org.hyperskill.app.track_selection.list.domain.analytic.TrackSelectionListClickedRetryContentLoadingHyperskillAnalyticEvent import org.hyperskill.app.track_selection.list.domain.analytic.TrackSelectionListTrackClickedHyperskillAnalyticEvent import org.hyperskill.app.track_selection.list.domain.analytic.TrackSelectionListViewedHyperskillAnalyticEvent import org.hyperskill.app.track_selection.list.injection.TrackSelectionListParams @@ -27,7 +27,7 @@ internal class TrackSelectionListReducer( State.Loading to setOf( InternalAction.FetchTracks, InternalAction.LogAnalyticEvent( - TrackSelectionListClickedRetryContentLoadingHyperskillAnalyticsEvent() + TrackSelectionListClickedRetryContentLoadingHyperskillAnalyticEvent ) ) } else { @@ -66,9 +66,7 @@ internal class TrackSelectionListReducer( } is Message.ViewedEventMessage -> state to setOf( - InternalAction.LogAnalyticEvent( - TrackSelectionListViewedHyperskillAnalyticEvent() - ) + InternalAction.LogAnalyticEvent(TrackSelectionListViewedHyperskillAnalyticEvent) ) } ?: (state to emptySet()) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/cache/UsersQuestionnaireCacheDataSourceImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/cache/UsersQuestionnaireCacheDataSourceImpl.kt new file mode 100644 index 0000000000..8923ed2bda --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/cache/UsersQuestionnaireCacheDataSourceImpl.kt @@ -0,0 +1,15 @@ +package org.hyperskill.app.users_questionnaire.cache + +import com.russhwolf.settings.Settings +import org.hyperskill.app.users_questionnaire.data.source.UsersQuestionnaireCacheDataSource + +internal class UsersQuestionnaireCacheDataSourceImpl( + private val settings: Settings +) : UsersQuestionnaireCacheDataSource { + override fun getIsUsersQuestionnaireWidgetHidden(): Boolean = + settings.getBoolean(UsersQuestionnaireCacheKeyValues.USERS_QUESTIONNAIRE_WIDGET_HIDDEN, false) + + override fun setIsUsersQuestionnaireWidgetHidden(isHidden: Boolean) { + settings.putBoolean(UsersQuestionnaireCacheKeyValues.USERS_QUESTIONNAIRE_WIDGET_HIDDEN, isHidden) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/cache/UsersQuestionnaireCacheKeyValues.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/cache/UsersQuestionnaireCacheKeyValues.kt new file mode 100644 index 0000000000..7c89f915eb --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/cache/UsersQuestionnaireCacheKeyValues.kt @@ -0,0 +1,5 @@ +package org.hyperskill.app.users_questionnaire.cache + +internal object UsersQuestionnaireCacheKeyValues { + const val USERS_QUESTIONNAIRE_WIDGET_HIDDEN = "users_questionnaire_widget_hidden" +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/data/repository/UsersQuestionnaireRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/data/repository/UsersQuestionnaireRepositoryImpl.kt new file mode 100644 index 0000000000..72dd17a349 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/data/repository/UsersQuestionnaireRepositoryImpl.kt @@ -0,0 +1,15 @@ +package org.hyperskill.app.users_questionnaire.data.repository + +import org.hyperskill.app.users_questionnaire.data.source.UsersQuestionnaireCacheDataSource +import org.hyperskill.app.users_questionnaire.domain.repository.UsersQuestionnaireRepository + +internal class UsersQuestionnaireRepositoryImpl( + private val usersQuestionnaireCacheDataSource: UsersQuestionnaireCacheDataSource +) : UsersQuestionnaireRepository { + override fun getIsUsersQuestionnaireWidgetHidden(): Boolean = + usersQuestionnaireCacheDataSource.getIsUsersQuestionnaireWidgetHidden() + + override fun setIsUsersQuestionnaireWidgetHidden(isHidden: Boolean) { + usersQuestionnaireCacheDataSource.setIsUsersQuestionnaireWidgetHidden(isHidden) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/data/source/UsersQuestionnaireCacheDataSource.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/data/source/UsersQuestionnaireCacheDataSource.kt new file mode 100644 index 0000000000..f00a209beb --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/data/source/UsersQuestionnaireCacheDataSource.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.users_questionnaire.data.source + +interface UsersQuestionnaireCacheDataSource { + fun getIsUsersQuestionnaireWidgetHidden(): Boolean + fun setIsUsersQuestionnaireWidgetHidden(isHidden: Boolean) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/domain/repository/UsersQuestionnaireRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/domain/repository/UsersQuestionnaireRepository.kt new file mode 100644 index 0000000000..34301dab72 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/domain/repository/UsersQuestionnaireRepository.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.users_questionnaire.domain.repository + +interface UsersQuestionnaireRepository { + fun getIsUsersQuestionnaireWidgetHidden(): Boolean + fun setIsUsersQuestionnaireWidgetHidden(isHidden: Boolean) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/injection/UsersQuestionnaireDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/injection/UsersQuestionnaireDataComponent.kt new file mode 100644 index 0000000000..5664e2229d --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/injection/UsersQuestionnaireDataComponent.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.users_questionnaire.injection + +import org.hyperskill.app.users_questionnaire.domain.repository.UsersQuestionnaireRepository + +interface UsersQuestionnaireDataComponent { + val usersQuestionnaireRepository: UsersQuestionnaireRepository +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/injection/UsersQuestionnaireDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/injection/UsersQuestionnaireDataComponentImpl.kt new file mode 100644 index 0000000000..2f8ad533b5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/injection/UsersQuestionnaireDataComponentImpl.kt @@ -0,0 +1,17 @@ +package org.hyperskill.app.users_questionnaire.injection + +import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.users_questionnaire.cache.UsersQuestionnaireCacheDataSourceImpl +import org.hyperskill.app.users_questionnaire.data.repository.UsersQuestionnaireRepositoryImpl +import org.hyperskill.app.users_questionnaire.data.source.UsersQuestionnaireCacheDataSource +import org.hyperskill.app.users_questionnaire.domain.repository.UsersQuestionnaireRepository + +internal class UsersQuestionnaireDataComponentImpl( + appGraph: AppGraph +) : UsersQuestionnaireDataComponent { + private val usersQuestionnaireCacheDataSource: UsersQuestionnaireCacheDataSource = + UsersQuestionnaireCacheDataSourceImpl(appGraph.commonComponent.settings) + + override val usersQuestionnaireRepository: UsersQuestionnaireRepository + get() = UsersQuestionnaireRepositoryImpl(usersQuestionnaireCacheDataSource) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingAnalyticParams.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingAnalyticParams.kt new file mode 100644 index 0000000000..dae5130b66 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingAnalyticParams.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.users_questionnaire.onboarding.domain.analytic + +internal object UsersQuestionnaireOnboardingAnalyticParams { + const val PARAM_SOURCE = "source" + const val PARAM_INPUT = "input" +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingClickedChoiceHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingClickedChoiceHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..d4e6f7f580 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingClickedChoiceHyperskillAnalyticEvent.kt @@ -0,0 +1,41 @@ +package org.hyperskill.app.users_questionnaire.onboarding.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents click on the choice analytic event. + * + * JSON payload: + * ``` + * { + * "route": "/onboarding/questionnaire", + * "action": "click", + * "part": "main", + * "target": "choice", + * "context": + * { + * "source": "Google Play" + * } + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class UsersQuestionnaireOnboardingClickedChoiceHyperskillAnalyticEvent( + private val selectedChoice: String +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Onboarding.UsersQuestionnaire, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.CHOICE +) { + override val params: Map + get() = super.params + + mapOf( + PARAM_CONTEXT to mapOf(UsersQuestionnaireOnboardingAnalyticParams.PARAM_SOURCE to selectedChoice) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingClickedSendHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingClickedSendHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..63156b440c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingClickedSendHyperskillAnalyticEvent.kt @@ -0,0 +1,47 @@ +package org.hyperskill.app.users_questionnaire.onboarding.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget +import ru.nobird.app.core.model.mapOfNotNull + +/** + * Represents click on the "Send" button analytic event. + * + * JSON payload: + * ``` + * { + * "route": "/onboarding/questionnaire", + * "action": "click", + * "part": "main", + * "target": "send", + * "context": + * { + * "source": "Google Play", + * "input": "Some text" + * } + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +class UsersQuestionnaireOnboardingClickedSendHyperskillAnalyticEvent( + private val selectedChoice: String, + private val textInputValue: String? +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Onboarding.UsersQuestionnaire, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.SEND +) { + override val params: Map + get() = super.params + + mapOf( + PARAM_CONTEXT to mapOfNotNull( + UsersQuestionnaireOnboardingAnalyticParams.PARAM_SOURCE to selectedChoice, + UsersQuestionnaireOnboardingAnalyticParams.PARAM_INPUT to textInputValue + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingClickedSkipHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingClickedSkipHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..50a51046f2 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingClickedSkipHyperskillAnalyticEvent.kt @@ -0,0 +1,29 @@ +package org.hyperskill.app.users_questionnaire.onboarding.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents click on the "Skip" button analytic event. + * + * JSON payload: + * ``` + * { + * "route": "/onboarding/questionnaire", + * "action": "click", + * "part": "main", + * "target": "skip" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object UsersQuestionnaireOnboardingClickedSkipHyperskillAnalyticEvent : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Onboarding.UsersQuestionnaire, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.SKIP +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingViewedHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingViewedHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..1d5ab8ebf5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/domain/analytic/UsersQuestionnaireOnboardingViewedHyperskillAnalyticEvent.kt @@ -0,0 +1,23 @@ +package org.hyperskill.app.users_questionnaire.onboarding.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute + +/** + * Represents a view analytic event. + * + * JSON payload: + * ``` + * { + * "route": "/onboarding/questionnaire", + * "action": "view" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object UsersQuestionnaireOnboardingViewedHyperskillAnalyticEvent : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Onboarding.UsersQuestionnaire, + HyperskillAnalyticAction.VIEW +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/UsersQuestionnaireOnboardingComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/UsersQuestionnaireOnboardingComponent.kt new file mode 100644 index 0000000000..358b880471 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/UsersQuestionnaireOnboardingComponent.kt @@ -0,0 +1,10 @@ +package org.hyperskill.app.users_questionnaire.onboarding.injection + +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Action +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Message +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.ViewState +import ru.nobird.app.presentation.redux.feature.Feature + +interface UsersQuestionnaireOnboardingComponent { + val usersQuestionnaireOnboardingFeature: Feature +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/UsersQuestionnaireOnboardingComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/UsersQuestionnaireOnboardingComponentImpl.kt new file mode 100644 index 0000000000..cc493fa324 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/UsersQuestionnaireOnboardingComponentImpl.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.users_questionnaire.onboarding.injection + +import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Action +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Message +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.ViewState +import ru.nobird.app.presentation.redux.feature.Feature + +internal class UsersQuestionnaireOnboardingComponentImpl( + private val appGraph: AppGraph +) : UsersQuestionnaireOnboardingComponent { + override val usersQuestionnaireOnboardingFeature: Feature + get() = UsersQuestionnaireOnboardingFeatureBuilder.build( + analyticInteractor = appGraph.analyticComponent.analyticInteractor, + buildVariant = appGraph.commonComponent.buildKonfig.buildVariant, + logger = appGraph.loggerComponent.logger, + platform = appGraph.commonComponent.platform, + resourceProvider = appGraph.commonComponent.resourceProvider + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/UsersQuestionnaireOnboardingFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/UsersQuestionnaireOnboardingFeatureBuilder.kt new file mode 100644 index 0000000000..7776497a74 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/injection/UsersQuestionnaireOnboardingFeatureBuilder.kt @@ -0,0 +1,51 @@ +package org.hyperskill.app.users_questionnaire.onboarding.injection + +import co.touchlab.kermit.Logger +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.core.domain.BuildVariant +import org.hyperskill.app.core.domain.platform.Platform +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.core.presentation.transformState +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.logging.presentation.wrapWithLogger +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingActionDispatcher +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Action +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Message +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.ViewState +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingReducer +import org.hyperskill.app.users_questionnaire.onboarding.view.mapper.UsersQuestionnaireOnboardingViewStateMapper +import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher +import ru.nobird.app.presentation.redux.feature.Feature +import ru.nobird.app.presentation.redux.feature.ReduxFeature + +internal object UsersQuestionnaireOnboardingFeatureBuilder { + private const val LOG_TAG = "UsersQuestionnaireOnboardingFeature" + + fun build( + analyticInteractor: AnalyticInteractor, + buildVariant: BuildVariant, + logger: Logger, + platform: Platform, + resourceProvider: ResourceProvider + ): Feature { + val reducer = UsersQuestionnaireOnboardingReducer(resourceProvider) + .wrapWithLogger(buildVariant, logger, LOG_TAG) + val actionDispatcher = UsersQuestionnaireOnboardingActionDispatcher( + config = ActionDispatcherOptions(), + analyticInteractor = analyticInteractor + ) + + val viewStateMapper = UsersQuestionnaireOnboardingViewStateMapper( + platform = platform, + resourceProvider = resourceProvider + ) + + return ReduxFeature( + initialState = UsersQuestionnaireOnboardingFeature.State(), + reducer = reducer + ) + .wrapWithActionDispatcher(actionDispatcher) + .transformState(viewStateMapper::mapState) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/presentation/UsersQuestionnaireOnboardingActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/presentation/UsersQuestionnaireOnboardingActionDispatcher.kt new file mode 100644 index 0000000000..cfafdeba1a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/presentation/UsersQuestionnaireOnboardingActionDispatcher.kt @@ -0,0 +1,23 @@ +package org.hyperskill.app.users_questionnaire.onboarding.presentation + +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Action +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.InternalAction +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Message +import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher + +internal class UsersQuestionnaireOnboardingActionDispatcher( + config: ActionDispatcherOptions, + private val analyticInteractor: AnalyticInteractor +) : CoroutineActionDispatcher(config.createConfig()) { + override suspend fun doSuspendableAction(action: Action) { + when (action) { + is InternalAction.LogAnalyticEvent -> + analyticInteractor.logEvent(action.event, action.forceLogEvent) + else -> { + // no op + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/presentation/UsersQuestionnaireOnboardingFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/presentation/UsersQuestionnaireOnboardingFeature.kt new file mode 100644 index 0000000000..1679fcb99a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/presentation/UsersQuestionnaireOnboardingFeature.kt @@ -0,0 +1,43 @@ +package org.hyperskill.app.users_questionnaire.onboarding.presentation + +import org.hyperskill.app.analytic.domain.model.AnalyticEvent + +object UsersQuestionnaireOnboardingFeature { + internal data class State( + val selectedChoice: String? = null, + val textInputValue: String? = null + ) + + data class ViewState( + val title: String, + val choices: List, + val selectedChoice: String?, + val textInputValue: String?, + val isTextInputVisible: Boolean, + val isSendButtonEnabled: Boolean + ) + + sealed interface Message { + data class ClickedChoice(val choice: String) : Message + data class TextInputValueChanged(val text: String) : Message + + object SendButtonClicked : Message + object SkipButtonClicked : Message + + object ViewedEventMessage : Message + } + + sealed interface Action { + sealed interface ViewAction : Action { + object CompleteUsersQuestionnaireOnboarding : ViewAction + data class ShowSendSuccessMessage(val message: String) : ViewAction + } + } + + internal sealed interface InternalAction : Action { + data class LogAnalyticEvent( + val event: AnalyticEvent, + val forceLogEvent: Boolean = false + ) : InternalAction + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/presentation/UsersQuestionnaireOnboardingReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/presentation/UsersQuestionnaireOnboardingReducer.kt new file mode 100644 index 0000000000..e512f49983 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/presentation/UsersQuestionnaireOnboardingReducer.kt @@ -0,0 +1,76 @@ +package org.hyperskill.app.users_questionnaire.onboarding.presentation + +import org.hyperskill.app.SharedResources +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.users_questionnaire.onboarding.domain.analytic.UsersQuestionnaireOnboardingClickedChoiceHyperskillAnalyticEvent +import org.hyperskill.app.users_questionnaire.onboarding.domain.analytic.UsersQuestionnaireOnboardingClickedSendHyperskillAnalyticEvent +import org.hyperskill.app.users_questionnaire.onboarding.domain.analytic.UsersQuestionnaireOnboardingClickedSkipHyperskillAnalyticEvent +import org.hyperskill.app.users_questionnaire.onboarding.domain.analytic.UsersQuestionnaireOnboardingViewedHyperskillAnalyticEvent +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Action +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.InternalAction +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.Message +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.State +import ru.nobird.app.presentation.redux.reducer.StateReducer + +private typealias ReducerResult = Pair> + +internal class UsersQuestionnaireOnboardingReducer( + private val resourceProvider: ResourceProvider +) : StateReducer { + override fun reduce(state: State, message: Message): ReducerResult = + when (message) { + is Message.ClickedChoice -> + state.copy(selectedChoice = message.choice) to setOf( + InternalAction.LogAnalyticEvent( + UsersQuestionnaireOnboardingClickedChoiceHyperskillAnalyticEvent(message.choice) + ) + ) + is Message.TextInputValueChanged -> + handleTextInputValueChangedMessage(state, message) + Message.SendButtonClicked -> + handleSendButtonClickedMessage(state) + Message.SkipButtonClicked -> + handleSkipButtonClickedMessage(state) + Message.ViewedEventMessage -> + state to setOf( + InternalAction.LogAnalyticEvent(UsersQuestionnaireOnboardingViewedHyperskillAnalyticEvent) + ) + } ?: (state to emptySet()) + + private fun handleTextInputValueChangedMessage( + state: State, + message: Message.TextInputValueChanged + ): ReducerResult? = + if (state.selectedChoice != null) { + state.copy(textInputValue = message.text) to emptySet() + } else { + null + } + + private fun handleSendButtonClickedMessage(state: State): ReducerResult? = + if (state.selectedChoice != null) { + state to setOf( + InternalAction.LogAnalyticEvent( + UsersQuestionnaireOnboardingClickedSendHyperskillAnalyticEvent( + selectedChoice = state.selectedChoice, + textInputValue = state.textInputValue + ), + forceLogEvent = true + ), + Action.ViewAction.ShowSendSuccessMessage( + resourceProvider.getString( + SharedResources.strings.users_questionnaire_onboarding_send_answer_success_message + ) + ), + Action.ViewAction.CompleteUsersQuestionnaireOnboarding + ) + } else { + null + } + + private fun handleSkipButtonClickedMessage(state: State): ReducerResult = + state to setOf( + InternalAction.LogAnalyticEvent(UsersQuestionnaireOnboardingClickedSkipHyperskillAnalyticEvent), + Action.ViewAction.CompleteUsersQuestionnaireOnboarding + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/view/mapper/UsersQuestionnaireOnboardingViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/view/mapper/UsersQuestionnaireOnboardingViewStateMapper.kt new file mode 100644 index 0000000000..96818d5774 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/onboarding/view/mapper/UsersQuestionnaireOnboardingViewStateMapper.kt @@ -0,0 +1,53 @@ +package org.hyperskill.app.users_questionnaire.onboarding.view.mapper + +import org.hyperskill.app.SharedResources +import org.hyperskill.app.core.domain.platform.Platform +import org.hyperskill.app.core.domain.platform.PlatformType +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.State +import org.hyperskill.app.users_questionnaire.onboarding.presentation.UsersQuestionnaireOnboardingFeature.ViewState + +internal class UsersQuestionnaireOnboardingViewStateMapper( + platform: Platform, + resourceProvider: ResourceProvider +) { + private val title = resourceProvider.getString( + SharedResources.strings.users_questionnaire_onboarding_title_template, + resourceProvider.getString(platform.appNameResource) + ) + + private val choices = listOf( + when (platform.platformType) { + PlatformType.IOS -> + resourceProvider.getString(SharedResources.strings.users_questionnaire_onboarding_source_app_store) + PlatformType.ANDROID -> + resourceProvider.getString(SharedResources.strings.users_questionnaire_onboarding_source_google_play) + }, + resourceProvider.getString(SharedResources.strings.users_questionnaire_onboarding_source_google_search), + resourceProvider.getString(SharedResources.strings.users_questionnaire_onboarding_source_youtube), + resourceProvider.getString(SharedResources.strings.users_questionnaire_onboarding_source_instagram), + resourceProvider.getString(SharedResources.strings.users_questionnaire_onboarding_source_tiktok), + resourceProvider.getString(SharedResources.strings.users_questionnaire_onboarding_source_news), + resourceProvider.getString(SharedResources.strings.users_questionnaire_onboarding_source_friends), + resourceProvider.getString(SharedResources.strings.users_questionnaire_onboarding_source_other) + ) + + fun mapState(state: State): ViewState { + val isTextInputVisible = state.selectedChoice == choices.last() + + val isSendButtonEnabled = if (isTextInputVisible) { + state.textInputValue?.isNotBlank() == true + } else { + state.selectedChoice != null + } + + return ViewState( + title = title, + choices = choices, + selectedChoice = state.selectedChoice, + textInputValue = state.textInputValue.takeIf { isTextInputVisible }, + isTextInputVisible = isTextInputVisible, + isSendButtonEnabled = isSendButtonEnabled + ) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/domain/analytic/UsersQuestionnaireWidgetClickedCloseHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/domain/analytic/UsersQuestionnaireWidgetClickedCloseHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..d3c8f4b2df --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/domain/analytic/UsersQuestionnaireWidgetClickedCloseHyperskillAnalyticEvent.kt @@ -0,0 +1,29 @@ +package org.hyperskill.app.users_questionnaire.widget.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents an analytic event for clicking on a close button in the users questionnaire widget. + * + * JSON payload: + * ``` + * { + * "route": "/study-plan", + * "action": "click", + * "part": "users_questionnaire_widget", + * "target": "close" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object UsersQuestionnaireWidgetClickedCloseHyperskillAnalyticEvent : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.StudyPlan(), + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.USERS_QUESTIONNAIRE_WIDGET, + HyperskillAnalyticTarget.CLOSE +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/domain/analytic/UsersQuestionnaireWidgetClickedHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/domain/analytic/UsersQuestionnaireWidgetClickedHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..b234cb1036 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/domain/analytic/UsersQuestionnaireWidgetClickedHyperskillAnalyticEvent.kt @@ -0,0 +1,26 @@ +package org.hyperskill.app.users_questionnaire.widget.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute + +/** + * Represents a click analytic event of the users questionnaire widget. + * + * JSON payload: + * ``` + * { + * "route": "/study-plan", + * "action": "click", + * "part": "users_questionnaire_widget" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object UsersQuestionnaireWidgetClickedHyperskillAnalyticEvent : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.StudyPlan(), + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.USERS_QUESTIONNAIRE_WIDGET +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/domain/analytic/UsersQuestionnaireWidgetViewedHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/domain/analytic/UsersQuestionnaireWidgetViewedHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..f3d7d7e5e7 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/domain/analytic/UsersQuestionnaireWidgetViewedHyperskillAnalyticEvent.kt @@ -0,0 +1,23 @@ +package org.hyperskill.app.users_questionnaire.widget.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute + +/** + * Represents a view analytic event of the users questionnaire widget. + * + * JSON payload: + * ``` + * { + * "route": "/study-plan/users-questionnaire-widget", + * "action": "view" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object UsersQuestionnaireWidgetViewedHyperskillAnalyticEvent : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.StudyPlan.UsersQuestionnaireWidget(), + HyperskillAnalyticAction.VIEW +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/injection/UsersQuestionnaireWidgetComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/injection/UsersQuestionnaireWidgetComponent.kt new file mode 100644 index 0000000000..ca037c75fc --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/injection/UsersQuestionnaireWidgetComponent.kt @@ -0,0 +1,9 @@ +package org.hyperskill.app.users_questionnaire.widget.injection + +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetActionDispatcher +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetReducer + +interface UsersQuestionnaireWidgetComponent { + val usersQuestionnaireWidgetReducer: UsersQuestionnaireWidgetReducer + val usersQuestionnaireWidgetActionDispatcher: UsersQuestionnaireWidgetActionDispatcher +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/injection/UsersQuestionnaireWidgetComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/injection/UsersQuestionnaireWidgetComponentImpl.kt new file mode 100644 index 0000000000..f2b2f4cff4 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/injection/UsersQuestionnaireWidgetComponentImpl.kt @@ -0,0 +1,21 @@ +package org.hyperskill.app.users_questionnaire.widget.injection + +import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetActionDispatcher +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetReducer + +internal class UsersQuestionnaireWidgetComponentImpl( + private val appGraph: AppGraph +) : UsersQuestionnaireWidgetComponent { + override val usersQuestionnaireWidgetReducer: UsersQuestionnaireWidgetReducer + get() = UsersQuestionnaireWidgetReducer() + + override val usersQuestionnaireWidgetActionDispatcher: UsersQuestionnaireWidgetActionDispatcher + get() = UsersQuestionnaireWidgetActionDispatcher( + config = ActionDispatcherOptions(), + usersQuestionnaireRepository = appGraph.buildUsersQuestionnaireDataComponent().usersQuestionnaireRepository, + currentProfileStateRepository = appGraph.profileDataComponent.currentProfileStateRepository, + analyticInteractor = appGraph.analyticComponent.analyticInteractor + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/presentation/UsersQuestionnaireWidgetActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/presentation/UsersQuestionnaireWidgetActionDispatcher.kt new file mode 100644 index 0000000000..b1e0969564 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/presentation/UsersQuestionnaireWidgetActionDispatcher.kt @@ -0,0 +1,80 @@ +package org.hyperskill.app.users_questionnaire.widget.presentation + +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.profile.domain.model.isMobileUsersQuestionnaireEnabled +import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository +import org.hyperskill.app.users_questionnaire.domain.repository.UsersQuestionnaireRepository +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature.Action +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature.InternalAction +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature.InternalMessage +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature.Message +import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher + +class UsersQuestionnaireWidgetActionDispatcher( + config: ActionDispatcherOptions, + private val usersQuestionnaireRepository: UsersQuestionnaireRepository, + private val currentProfileStateRepository: CurrentProfileStateRepository, + private val analyticInteractor: AnalyticInteractor +) : CoroutineActionDispatcher(config.createConfig()) { + init { + currentProfileStateRepository.changes + .map { it.features.isMobileUsersQuestionnaireEnabled } + .distinctUntilChanged() + .onEach { isMobileUsersQuestionnaireEnabled -> + onNewMessage( + InternalMessage.UsersQuestionnaireFeatureFlagChanged( + isMobileUsersQuestionnaireEnabled + ) + ) + } + .launchIn(actionScope) + } + + override suspend fun doSuspendableAction(action: Action) { + when (action) { + InternalAction.FetchUsersQuestionnaireWidgetData -> + handleFetchUsersQuestionnaireWidgetDataAction(::onNewMessage) + InternalAction.FetchUsersQuestionnaireUrl -> + handleFetchUsersQuestionnaireUrlAction(::onNewMessage) + InternalAction.HideUsersQuestionnaireWidget -> + usersQuestionnaireRepository.setIsUsersQuestionnaireWidgetHidden(true) + is InternalAction.LogAnalyticEvent -> + analyticInteractor.logEvent(action.analyticEvent) + else -> { + // no op + } + } + } + + private suspend fun handleFetchUsersQuestionnaireWidgetDataAction(onNewMessage: (Message) -> Unit) { + val isMobileUsersQuestionnaireEnabled = currentProfileStateRepository + .getState(forceUpdate = false) + .map { it.features.isMobileUsersQuestionnaireEnabled } + .getOrDefault(defaultValue = false) + + onNewMessage( + InternalMessage.FetchUsersQuestionnaireWidgetDataResult( + isUsersQuestionnaireEnabled = isMobileUsersQuestionnaireEnabled, + isUsersQuestionnaireWidgetHidden = usersQuestionnaireRepository.getIsUsersQuestionnaireWidgetHidden() + ) + ) + } + + private suspend fun handleFetchUsersQuestionnaireUrlAction(onNewMessage: (Message) -> Unit) { + val currentUserId = currentProfileStateRepository + .getState(forceUpdate = false) + .map { it.id } + .getOrNull() ?: return + + onNewMessage( + InternalMessage.FetchUsersQuestionnaireUrlResult( + "https://docs.google.com/forms/d/e/1FAIpQLSf6k3woOqZr2zfmbBNvA71DyD04LN4v7l6k-vuyqdAmdMUnOA/viewform?usp=pp_url&entry.193481738=$currentUserId" // ktlint-disable + ) + ) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/presentation/UsersQuestionnaireWidgetFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/presentation/UsersQuestionnaireWidgetFeature.kt new file mode 100644 index 0000000000..35033ff5ef --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/presentation/UsersQuestionnaireWidgetFeature.kt @@ -0,0 +1,52 @@ +package org.hyperskill.app.users_questionnaire.widget.presentation + +import org.hyperskill.app.analytic.domain.model.AnalyticEvent + +object UsersQuestionnaireWidgetFeature { + sealed interface State { + object Idle : State + object Loading : State + object Hidden : State + object Visible : State + } + + sealed interface Message { + object CloseClicked : Message + object WidgetClicked : Message + + object ViewedEventMessage : Message + } + + internal sealed interface InternalMessage : Message { + object Initialize : InternalMessage + + data class FetchUsersQuestionnaireWidgetDataResult( + val isUsersQuestionnaireEnabled: Boolean, + val isUsersQuestionnaireWidgetHidden: Boolean + ) : InternalMessage + + data class UsersQuestionnaireFeatureFlagChanged( + val isUsersQuestionnaireEnabled: Boolean + ) : InternalMessage + + data class FetchUsersQuestionnaireUrlResult( + val url: String + ) : InternalMessage + } + + sealed interface Action { + sealed interface ViewAction : Action { + data class ShowUsersQuestionnaire(val url: String) : ViewAction + } + } + + internal sealed interface InternalAction : Action { + object FetchUsersQuestionnaireWidgetData : InternalAction + + object FetchUsersQuestionnaireUrl : InternalAction + + object HideUsersQuestionnaireWidget : InternalAction + + data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/presentation/UsersQuestionnaireWidgetReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/presentation/UsersQuestionnaireWidgetReducer.kt new file mode 100644 index 0000000000..e6e5e29513 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/users_questionnaire/widget/presentation/UsersQuestionnaireWidgetReducer.kt @@ -0,0 +1,64 @@ +package org.hyperskill.app.users_questionnaire.widget.presentation + +import org.hyperskill.app.users_questionnaire.widget.domain.analytic.UsersQuestionnaireWidgetClickedCloseHyperskillAnalyticEvent +import org.hyperskill.app.users_questionnaire.widget.domain.analytic.UsersQuestionnaireWidgetClickedHyperskillAnalyticEvent +import org.hyperskill.app.users_questionnaire.widget.domain.analytic.UsersQuestionnaireWidgetViewedHyperskillAnalyticEvent +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature.Action +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature.InternalAction +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature.InternalMessage +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature.Message +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature.State +import ru.nobird.app.presentation.redux.reducer.StateReducer + +class UsersQuestionnaireWidgetReducer : StateReducer { + override fun reduce(state: State, message: Message): Pair> = + when (message) { + InternalMessage.Initialize -> + if (state is State.Idle) { + State.Loading to setOf(InternalAction.FetchUsersQuestionnaireWidgetData) + } else { + null + } + is InternalMessage.FetchUsersQuestionnaireWidgetDataResult -> + if (message.isUsersQuestionnaireEnabled && !message.isUsersQuestionnaireWidgetHidden) { + State.Visible to emptySet() + } else { + State.Hidden to emptySet() + } + is InternalMessage.UsersQuestionnaireFeatureFlagChanged -> + if (state is State.Visible && !message.isUsersQuestionnaireEnabled) { + State.Hidden to emptySet() + } else { + null + } + Message.CloseClicked -> + if (state is State.Visible) { + State.Hidden to setOf( + InternalAction.HideUsersQuestionnaireWidget, + InternalAction.LogAnalyticEvent(UsersQuestionnaireWidgetClickedCloseHyperskillAnalyticEvent) + ) + } else { + null + } + Message.WidgetClicked -> + if (state is State.Visible) { + State.Hidden to setOf( + InternalAction.FetchUsersQuestionnaireUrl, + InternalAction.HideUsersQuestionnaireWidget, + InternalAction.LogAnalyticEvent(UsersQuestionnaireWidgetClickedHyperskillAnalyticEvent) + ) + } else { + null + } + is InternalMessage.FetchUsersQuestionnaireUrlResult -> + state to setOf(Action.ViewAction.ShowUsersQuestionnaire(url = message.url)) + Message.ViewedEventMessage -> + if (state is State.Visible) { + state to setOf( + InternalAction.LogAnalyticEvent(UsersQuestionnaireWidgetViewedHyperskillAnalyticEvent) + ) + } else { + null + } + } ?: (state to emptySet()) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/injection/WelcomeOnboardingComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/injection/WelcomeOnboardingComponentImpl.kt index 8059f25790..dfefaa6d63 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/injection/WelcomeOnboardingComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/injection/WelcomeOnboardingComponentImpl.kt @@ -9,11 +9,14 @@ internal class WelcomeOnboardingComponentImpl( private val appGraph: AppGraph ) : WelcomeOnboardingComponent { override val welcomeOnboardingReducer: WelcomeOnboardingReducer - get() = WelcomeOnboardingReducer() + get() = WelcomeOnboardingReducer( + isSubscriptionPurchaseEnabled = appGraph.commonComponent.platform.isSubscriptionPurchaseEnabled + ) override val welcomeOnboardingActionDispatcher: WelcomeOnboardingActionDispatcher get() = WelcomeOnboardingActionDispatcher( config = ActionDispatcherOptions(), - onboardingInteractor = appGraph.buildOnboardingDataComponent().onboardingInteractor + onboardingInteractor = appGraph.buildOnboardingDataComponent().onboardingInteractor, + currentSubscriptionStateRepository = appGraph.stateRepositoriesComponent.currentSubscriptionStateRepository ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/presentation/WelcomeOnboardingActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/presentation/WelcomeOnboardingActionDispatcher.kt index f412f6ce3f..8717cbe68d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/presentation/WelcomeOnboardingActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/presentation/WelcomeOnboardingActionDispatcher.kt @@ -2,6 +2,7 @@ package org.hyperskill.app.welcome_onboarding.presentation import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.onboarding.domain.interactor.OnboardingInteractor +import org.hyperskill.app.subscriptions.domain.repository.CurrentSubscriptionStateRepository import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature.Action import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature.InternalAction import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature.InternalMessage @@ -10,17 +11,11 @@ import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher class WelcomeOnboardingActionDispatcher( config: ActionDispatcherOptions, - private val onboardingInteractor: OnboardingInteractor + private val onboardingInteractor: OnboardingInteractor, + private val currentSubscriptionStateRepository: CurrentSubscriptionStateRepository ) : CoroutineActionDispatcher(config.createConfig()) { override suspend fun doSuspendableAction(action: Action) { when (action) { - InternalAction.FetchNotificationOnboardingData -> { - onNewMessage( - InternalMessage.NotificationOnboardingDataFetched( - wasNotificationOnboardingShown = onboardingInteractor.wasNotificationOnboardingShown() - ) - ) - } InternalAction.FetchFirstProblemOnboardingData -> { onNewMessage( InternalMessage.FirstProblemOnboardingDataFetched( @@ -28,6 +23,14 @@ class WelcomeOnboardingActionDispatcher( ) ) } + InternalAction.FetchSubscription -> { + currentSubscriptionStateRepository.getState() + .fold( + onSuccess = InternalMessage::FetchSubscriptionSuccess, + onFailure = { InternalMessage.FetchSubscriptionError } + ) + .let(::onNewMessage) + } else -> { // no op } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/presentation/WelcomeOnboardingFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/presentation/WelcomeOnboardingFeature.kt index 7a90b0cbb8..b552b97557 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/presentation/WelcomeOnboardingFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/presentation/WelcomeOnboardingFeature.kt @@ -1,18 +1,23 @@ package org.hyperskill.app.welcome_onboarding.presentation import kotlinx.serialization.Serializable +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource import org.hyperskill.app.profile.domain.model.Profile import org.hyperskill.app.step.domain.model.StepRoute +import org.hyperskill.app.subscriptions.domain.model.Subscription import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature.Action object WelcomeOnboardingFeature { - @Serializable data class State(val profile: Profile? = null) sealed interface Message { object NotificationOnboardingCompleted : Message + object PaywallCompleted : Message + + object UsersQuestionnaireOnboardingCompleted : Message + data class FirstProblemOnboardingCompleted(val firstProblemStepRoute: StepRoute?) : Message } @@ -22,39 +27,36 @@ object WelcomeOnboardingFeature { val isNotificationPermissionGranted: Boolean ) : InternalMessage - data class NotificationOnboardingDataFetched( - val wasNotificationOnboardingShown: Boolean - ) : InternalMessage - data class FirstProblemOnboardingDataFetched( val wasFirstProblemOnboardingShown: Boolean ) : InternalMessage - } - sealed interface OnboardingFlowFinishReason { - data class NotificationOnboardingFinished(val profile: Profile?) : OnboardingFlowFinishReason - object FirstProblemOnboardingFinished : OnboardingFlowFinishReason + data class FetchSubscriptionSuccess(val subscription: Subscription) : InternalMessage + + object FetchSubscriptionError : InternalMessage } sealed interface Action { - data class OnboardingFlowFinished( - val reason: OnboardingFlowFinishReason - ) : Action + data class OnboardingFlowFinished(val profile: Profile?) : Action sealed interface ViewAction : Action { sealed interface NavigateTo : ViewAction { object NotificationOnboardingScreen : NavigateTo + object UsersQuestionnaireOnboardingScreen : NavigateTo + data class FirstProblemOnboardingScreen(val isNewUserMode: Boolean) : NavigateTo data class StudyPlanWithStep(val stepRoute: StepRoute) : NavigateTo + + data class Paywall(val paywallTransitionSource: PaywallTransitionSource) : NavigateTo } } } internal sealed interface InternalAction : Action { - object FetchNotificationOnboardingData : InternalAction object FetchFirstProblemOnboardingData : InternalAction + object FetchSubscription : InternalAction } } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/presentation/WelcomeOnboardingReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/presentation/WelcomeOnboardingReducer.kt index 61a0aef50e..8f91d32f20 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/presentation/WelcomeOnboardingReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/welcome_onboarding/presentation/WelcomeOnboardingReducer.kt @@ -1,120 +1,122 @@ package org.hyperskill.app.welcome_onboarding.presentation -import org.hyperskill.app.profile.domain.model.Profile +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.profile.domain.model.isMobileOnlySubscriptionEnabled import org.hyperskill.app.profile.domain.model.isNewUser +import org.hyperskill.app.subscriptions.domain.model.isFreemium import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature.Action import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature.Action.ViewAction import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature.InternalAction import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature.InternalMessage import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature.Message -import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature.OnboardingFlowFinishReason import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingFeature.State import ru.nobird.app.presentation.redux.reducer.StateReducer private typealias ReducerResult = Pair> -class WelcomeOnboardingReducer : StateReducer { - +class WelcomeOnboardingReducer( + private val isSubscriptionPurchaseEnabled: Boolean +) : StateReducer { override fun reduce(state: State, message: Message): ReducerResult = when (message) { is InternalMessage.OnboardingFlowRequested -> handleOnboardingFlowRequested(message) - is InternalMessage.NotificationOnboardingDataFetched -> - handleNotificationOnboardingDataFetched(state, message) Message.NotificationOnboardingCompleted -> handleNotificationOnboardingCompleted(state) + Message.UsersQuestionnaireOnboardingCompleted -> + handleUsersQuestionnaireOnboardingCompleted(state) + + is InternalMessage.FetchSubscriptionSuccess -> + handleFetchSubscriptionSuccess(state, message) + is InternalMessage.FetchSubscriptionError -> + handleFetchSubscriptionError(state) + is Message.PaywallCompleted -> + handlePaywallCompleted(state) + is InternalMessage.FirstProblemOnboardingDataFetched -> handleFirstProblemOnboardingDataFetched(state, message) is Message.FirstProblemOnboardingCompleted -> - handleFirstProblemOnboardingCompleted(message) + handleFirstProblemOnboardingCompleted(state, message) } private fun handleOnboardingFlowRequested( message: InternalMessage.OnboardingFlowRequested - ): ReducerResult = - if (!message.isNotificationPermissionGranted) { - State(message.profile) to - setOf(InternalAction.FetchNotificationOnboardingData) + ): ReducerResult { + val state = State(message.profile) + return if (!message.isNotificationPermissionGranted) { + state to setOf(ViewAction.NavigateTo.NotificationOnboardingScreen) } else { - onNotificationOnboardingCompleted(message.profile) + handleNotificationOnboardingCompleted(state) } + } - private fun handleNotificationOnboardingDataFetched( - state: State, - message: InternalMessage.NotificationOnboardingDataFetched + private fun handleNotificationOnboardingCompleted( + state: State ): ReducerResult = - if (state.profile != null) { - if (!message.wasNotificationOnboardingShown) { - state to setOf(ViewAction.NavigateTo.NotificationOnboardingScreen) - } else { - onNotificationOnboardingCompleted(state.profile) - } + if (state.profile?.isNewUser == true) { + state to setOf(ViewAction.NavigateTo.UsersQuestionnaireOnboardingScreen) } else { - state to setOf( - Action.OnboardingFlowFinished( - OnboardingFlowFinishReason.NotificationOnboardingFinished(state.profile) - ) - ) + handleUsersQuestionnaireOnboardingCompleted(state) } - private fun handleNotificationOnboardingCompleted( + private fun handleUsersQuestionnaireOnboardingCompleted( state: State ): ReducerResult = - if (state.profile != null) { - onNotificationOnboardingCompleted(state.profile) + if (isSubscriptionPurchaseEnabled && state.profile?.features?.isMobileOnlySubscriptionEnabled == true) { + state to setOf(InternalAction.FetchSubscription) } else { - state to setOf( - Action.OnboardingFlowFinished( - OnboardingFlowFinishReason.NotificationOnboardingFinished(state.profile) - ) - ) + handlePaywallCompleted(state) + } + + private fun handleFetchSubscriptionSuccess( + state: State, + message: InternalMessage.FetchSubscriptionSuccess + ): ReducerResult = + if (message.subscription.isFreemium) { + state to setOf(ViewAction.NavigateTo.Paywall(PaywallTransitionSource.LOGIN)) + } else { + handlePaywallCompleted(state) + } + + private fun handleFetchSubscriptionError(state: State): ReducerResult = + handlePaywallCompleted(state) + + private fun handlePaywallCompleted(state: State): ReducerResult = + if (state.profile?.isNewUser == false) { + state to setOf(InternalAction.FetchFirstProblemOnboardingData) + } else { + completeOnboardingFlow(state) } private fun handleFirstProblemOnboardingDataFetched( state: State, message: InternalMessage.FirstProblemOnboardingDataFetched ): ReducerResult = - if (state.profile?.isNewUser == false) { + if (state.profile?.isNewUser == false && !message.wasFirstProblemOnboardingShown) { state to setOf( - if (!message.wasFirstProblemOnboardingShown) { - ViewAction.NavigateTo.FirstProblemOnboardingScreen(isNewUserMode = false) - } else { - Action.OnboardingFlowFinished( - OnboardingFlowFinishReason.FirstProblemOnboardingFinished - ) - } + ViewAction.NavigateTo.FirstProblemOnboardingScreen(isNewUserMode = false) ) } else { - state to setOf( - Action.OnboardingFlowFinished( - OnboardingFlowFinishReason.FirstProblemOnboardingFinished - ) - ) + completeOnboardingFlow(state) } private fun handleFirstProblemOnboardingCompleted( + state: State, message: Message.FirstProblemOnboardingCompleted - ): ReducerResult = - State(profile = null) to setOf( - message.firstProblemStepRoute?.let { stepRoute -> - ViewAction.NavigateTo.StudyPlanWithStep(stepRoute) - } ?: Action.OnboardingFlowFinished( - OnboardingFlowFinishReason.FirstProblemOnboardingFinished - ) - ) + ): ReducerResult { + val (newState, newActions) = completeOnboardingFlow(state) - private fun onNotificationOnboardingCompleted( - profile: Profile - ): ReducerResult = - State(profile = profile.takeIf { !it.isNewUser }) to setOf( - if (profile.isNewUser) { - Action.OnboardingFlowFinished( - OnboardingFlowFinishReason.NotificationOnboardingFinished(profile) - ) - } else { - InternalAction.FetchFirstProblemOnboardingData - } - ) + val finalActions = if (message.firstProblemStepRoute != null) { + setOf(ViewAction.NavigateTo.StudyPlanWithStep(message.firstProblemStepRoute)) + } else { + newActions + } + + return newState to finalActions + } + + private fun completeOnboardingFlow(state: State): ReducerResult = + State(profile = null) to setOf(Action.OnboardingFlowFinished(state.profile)) } \ No newline at end of file diff --git a/shared/src/commonMain/resources/MR/base/strings.xml b/shared/src/commonMain/resources/MR/base/strings.xml index ad4d74c066..1634fed498 100644 --- a/shared/src/commonMain/resources/MR/base/strings.xml +++ b/shared/src/commonMain/resources/MR/base/strings.xml @@ -88,7 +88,6 @@ Send Checking Show discussions - Current quiz type is not supported in mobile app yet Problem of the day solved! Solve another one tomorrow to get more %s streak @@ -97,6 +96,11 @@ Continue with next topic Description + Current quiz type is not supported in mobile app yet + Web required + This problem is not supported in the mobile app. Please continue learning on the Web. + Solve on the Web version + See hint Report @@ -247,7 +251,6 @@ Send feedback academy@jetbrains.com Version - Rate this application Sign out Please confirm Are you sure you want to sign out? @@ -258,6 +261,14 @@ https://www.jetbrains.com/legal/terms/jetbrains-academy.html https://hi.hyperskill.org/terms https://support.hyperskill.org/hc/en-us/requests/new + Rate us in the App Store + https://apps.apple.com/app/id1637230833?action=write-review + Subscription + Mobile only + Try Mobile only plan for %s + Details + Rate us on the Play Store + https://play.google.com/store/apps/details?id=org.hyperskill.app.android Leaderboard @@ -417,14 +428,24 @@ Learn next - + You\'ve reached your daily limit + You\'ve used all %d of your lives for today + You\'ve solved %d problems today. Great job! Tomorrow new problems will be available to you. + Take a break and come back tomorrow for another exciting day of practice! + You\'ve solved %d problems today. Great job! Unlock unlimited problems with Mobile only plan. + Great job! Unlock unlimited lives with Mobile only plan. + + Unlock unlimited problems + Unlock unlimited lives %d/%d problems left Reset in %s + %d/%d lives left + Study plan for: %s @@ -485,7 +506,7 @@ No, thanks Streak successfully recovered Failed to recover a streak - Streak recovery successfully cancelled + Streak recovery cancelled Failed to cancel a streak recovery @@ -529,6 +550,28 @@ Oops! We were unable to load the challenge. Reload + + Tell us about your time using our app + + + How did you hear about\n%s? + + App Store + Google Play + Google Search + YouTube + Instagram + TikTok + News/article/blog + Friends/family + Other + + Type your answer here... + Send + Skip + + Answer sent! Enjoy 10 extra problems on the first day. + Interview preparation Oops! We were unable to load the interview widget @@ -559,6 +602,16 @@ Find topic Search all of Hyperskill for topic theory + + Do you enjoy\n%s app? + ๐Ÿ‘ Yes + ๐Ÿ‘Ž No + + Thank you! + Share what you disliked to help us improve your experience. + Write a request + Maybe later + Project Mastery Topic Mastery @@ -588,4 +641,33 @@ Wow! You\'ve reached level %d You\'ve earned the %s badge by reaching level %d! Amazing job! + + + Access to all tracks + Unlimited problems per day in the app + 1 hint per problem + + + Solve unlimited problems with Mobile only plan + Subscribe for %s/month + Continue with daily limits + Oops! We were unable to load the subscriptions data. + Subscription + Purchase failed. Please try again. + Paid functionality will become available to you shortly + Paid functionality will become available after success payment + The app is updating. Please wait a moment + Hyperskill Terms of Service and Privacy Policy + https://hi.hyperskill.org/terms + + + Subscription + Your current plan: + Hyperskill Mobile only + Valid until %s + Plan details: + Please be aware that with the Mobile ะพnly plan on hyperskill.org, there is a limit on the number of problems you can solve per day. + Manage subscription + Renew subscription + \ No newline at end of file diff --git a/shared/src/commonMain/resources/MR/colors/colors.xml b/shared/src/commonMain/resources/MR/colors/colors.xml index e6f9cf359d..1dac85c4b4 100644 --- a/shared/src/commonMain/resources/MR/colors/colors.xml +++ b/shared/src/commonMain/resources/MR/colors/colors.xml @@ -308,4 +308,9 @@ #FFFFFF #1F2427 + + + #C1C7CD + #697077 + \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/HyperskillUrlBuilderTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/HyperskillUrlBuilderTest.kt index ffcb20ab10..eb4a287b42 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/HyperskillUrlBuilderTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/HyperskillUrlBuilderTest.kt @@ -8,6 +8,7 @@ import org.hyperskill.app.core.domain.url.HyperskillUrlBuilder import org.hyperskill.app.core.domain.url.HyperskillUrlPath import org.hyperskill.app.debug.domain.model.EndpointConfigType import org.hyperskill.app.network.domain.model.NetworkEndpointConfigInfo +import org.hyperskill.app.step.domain.model.StepRoute class HyperskillUrlBuilderTest { @@ -34,7 +35,8 @@ class HyperskillUrlBuilderTest { HyperskillUrlPath.ResetPassword(), HyperskillUrlPath.StudyPlan(), HyperskillUrlPath.Track(1), - HyperskillUrlPath.DeleteAccount() + HyperskillUrlPath.DeleteAccount(), + HyperskillUrlPath.Step(StepRoute.Learn.Step(1)) ) for (path in paths) { @@ -48,6 +50,7 @@ class HyperskillUrlBuilderTest { is HyperskillUrlPath.StudyPlan -> "${networkEndpointConfigInfo.baseUrl}study-plan" is HyperskillUrlPath.Track -> "${networkEndpointConfigInfo.baseUrl}tracks/1" is HyperskillUrlPath.DeleteAccount -> "${networkEndpointConfigInfo.baseUrl}delete-account" + is HyperskillUrlPath.Step -> "${networkEndpointConfigInfo.baseUrl}learn/step/1" } assertEquals(expected, url.toString()) diff --git a/shared/src/commonTest/kotlin/org/hyperskill/StepQuizHintsViewStateMapperTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/StepQuizHintsViewStateMapperTest.kt index df85ca8a69..2a1610cd56 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/StepQuizHintsViewStateMapperTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/StepQuizHintsViewStateMapperTest.kt @@ -12,7 +12,7 @@ class StepQuizHintsViewStateMapperTest { hintsIds = emptyList(), currentHint = null, hintHasReaction = false, - isFreemiumEnabled = false, + areHintsLimited = false, stepId = 0L ) assertIs(StepQuizHintsViewStateMapper.mapState(featureState)) diff --git a/shared/src/commonTest/kotlin/org/hyperskill/core/view/mapper/date/SharedDateFormatterTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/core/view/mapper/date/SharedDateFormatterTest.kt index 0a372f2778..24a62504ad 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/core/view/mapper/date/SharedDateFormatterTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/core/view/mapper/date/SharedDateFormatterTest.kt @@ -5,6 +5,9 @@ import kotlin.test.assertEquals import kotlin.time.DurationUnit import kotlin.time.toDuration import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant import org.hyperskill.ResourceProviderStub import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter @@ -110,4 +113,15 @@ class SharedDateFormatterTest { val expected = "2 Nov" assertEquals(expected, dateFormatter.formatDayNumericAndMonthShort(given)) } + + @Test + fun `Format subscription valid until`() { + mapOf( + LocalDateTime.parse("2024-01-27T02:00:00") to "January 27, 2024, 02:00", + LocalDateTime.parse("2024-01-27T12:09:00") to "January 27, 2024, 12:09" + ).forEach { (localDate, expected) -> + val actual = localDate.toInstant(TimeZone.UTC) + assertEquals(expected, dateFormatter.formatSubscriptionValidUntil(actual, TimeZone.UTC)) + } + } } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/main/AppFeatureTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/main/AppFeatureTest.kt index 125ebae269..78816f7a80 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/main/AppFeatureTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/main/AppFeatureTest.kt @@ -1,6 +1,7 @@ package org.hyperskill.main import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertTrue import org.hyperskill.ResourceProviderStub import org.hyperskill.app.core.domain.platform.PlatformType @@ -10,27 +11,35 @@ import org.hyperskill.app.notification.click_handling.presentation.NotificationC import org.hyperskill.app.notification.remote.domain.model.PushNotificationCategory import org.hyperskill.app.notification.remote.domain.model.PushNotificationData import org.hyperskill.app.notification.remote.domain.model.PushNotificationType +import org.hyperskill.app.paywall.domain.model.PaywallTransitionSource +import org.hyperskill.app.profile.domain.model.FeatureKeys import org.hyperskill.app.profile.domain.model.Profile import org.hyperskill.app.streak_recovery.presentation.StreakRecoveryFeature import org.hyperskill.app.streak_recovery.presentation.StreakRecoveryReducer +import org.hyperskill.app.subscriptions.domain.model.Subscription +import org.hyperskill.app.subscriptions.domain.model.SubscriptionType import org.hyperskill.app.welcome_onboarding.presentation.WelcomeOnboardingReducer import org.hyperskill.profile.stub +import org.hyperskill.subscriptions.stub class AppFeatureTest { private val appReducer = AppReducer( - StreakRecoveryReducer(resourceProvider = ResourceProviderStub()), - NotificationClickHandlingReducer(), - WelcomeOnboardingReducer(), - PlatformType.ANDROID + streakRecoveryReducer = StreakRecoveryReducer(resourceProvider = ResourceProviderStub()), + notificationClickHandlingReducer = NotificationClickHandlingReducer(), + welcomeOnboardingReducer = WelcomeOnboardingReducer( + isSubscriptionPurchaseEnabled = true + ), + platformType = PlatformType.ANDROID ) @Test fun `Streak recovery should be initialized only when user is authorized and already selected track`() { val (_, actions) = appReducer.reduce( AppFeature.State.Loading, - AppFeature.Message.UserAccountStatus( - Profile.stub(isGuest = false, trackId = 1), - null + AppFeature.Message.FetchAppStartupConfigSuccess( + profile = Profile.stub(isGuest = false, trackId = 1), + notificationData = null, + subscription = null ) ) assertTrue { @@ -45,7 +54,11 @@ class AppFeatureTest { fun `Streak recovery should NOT be initialized when user is NOT authorized`() { val (_, actions) = appReducer.reduce( AppFeature.State.Loading, - AppFeature.Message.UserAccountStatus(Profile.stub(isGuest = true), null) + AppFeature.Message.FetchAppStartupConfigSuccess( + profile = Profile.stub(isGuest = true), + notificationData = null, + subscription = null + ) ) assertNoStreakRecoveryActions(actions) } @@ -54,9 +67,10 @@ class AppFeatureTest { fun `Streak recovery should NOT be initialized in case of push notification handling`() { val (_, actions) = appReducer.reduce( AppFeature.State.Loading, - AppFeature.Message.UserAccountStatus( - Profile.stub(isGuest = true, trackId = 1), - PushNotificationData( + AppFeature.Message.FetchAppStartupConfigSuccess( + profile = Profile.stub(isGuest = true, trackId = 1), + subscription = null, + notificationData = PushNotificationData( typeString = PushNotificationType.STREAK_NEW.name, categoryString = PushNotificationCategory.CONTINUE_LEARNING.backendName!! ) @@ -72,4 +86,102 @@ class AppFeatureTest { } } } + + @Test + fun `Paywall should be shown for user with freemium subscription on app startup`() { + val (state, actions) = appReducer.reduce( + AppFeature.State.Loading, + AppFeature.Message.FetchAppStartupConfigSuccess( + profile = Profile.stub( + isGuest = false, + trackId = 1, + featuresMap = mapOf(FeatureKeys.MOBILE_ONLY_SUBSCRIPTION to true) + ), + subscription = Subscription.stub(SubscriptionType.FREEMIUM), + notificationData = null + ) + ) + assertContains( + actions, + AppFeature.Action.ViewAction.NavigateTo.StudyPlanWithPaywall(PaywallTransitionSource.APP_BECOMES_ACTIVE) + ) + assertTrue { + state is AppFeature.State.Ready && state.appShowsCount == 1 + } + } + + @Test + fun `Paywall should not be shown for user with non freemium subscription on app startup`() { + SubscriptionType + .values() + .filterNot { it == SubscriptionType.FREEMIUM } + .forEach { subscriptionType -> + val (_, actions) = appReducer.reduce( + AppFeature.State.Loading, + AppFeature.Message.FetchAppStartupConfigSuccess( + profile = Profile.stub( + isGuest = false, + trackId = 1, + featuresMap = mapOf(FeatureKeys.MOBILE_ONLY_SUBSCRIPTION to true) + ), + subscription = Subscription.stub(subscriptionType), + notificationData = null + ) + ) + assertNoPaywallViewAction(actions) + } + } + + @Test + fun `Paywall should be shown for user with freemium subscription every 3 time when app shown`() { + var state: AppFeature.State = AppFeature.State.Ready( + isAuthorized = true, + isMobileLeaderboardsEnabled = false, + subscription = Subscription.stub(SubscriptionType.FREEMIUM), + isMobileOnlySubscriptionEnabled = true + ) + for (i in 1..AppReducer.APP_SHOWS_COUNT_TILL_PAYWALL + 1) { + val (newState, actions) = appReducer.reduce( + state, + AppFeature.Message.AppBecomesActive + ) + state = newState + + if (i % AppReducer.APP_SHOWS_COUNT_TILL_PAYWALL == 0) { + assertContains( + actions, + AppFeature.Action.ViewAction.NavigateTo.Paywall( + PaywallTransitionSource.APP_BECOMES_ACTIVE + ) + ) + } else { + assertNoPaywallViewAction(actions) + } + } + } + + @Test + fun `Paywall should not be shown if mobile only subscription feature is disabled`() { + val (_, actions) = appReducer.reduce( + AppFeature.State.Loading, + AppFeature.Message.FetchAppStartupConfigSuccess( + profile = Profile.stub( + isGuest = false, + trackId = 1, + featuresMap = mapOf(FeatureKeys.MOBILE_ONLY_SUBSCRIPTION to false) + ), + subscription = Subscription.stub(SubscriptionType.FREEMIUM), + notificationData = null + ) + ) + assertNoPaywallViewAction(actions) + } + + private fun assertNoPaywallViewAction(actions: Set) { + assertTrue { + actions.none { + it is AppFeature.Action.ViewAction.NavigateTo.StudyPlanWithPaywall + } + } + } } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/profile/ProfileStub.kt b/shared/src/commonTest/kotlin/org/hyperskill/profile/ProfileStub.kt index 8733cd4ab5..4d1e44fb66 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/profile/ProfileStub.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/profile/ProfileStub.kt @@ -8,7 +8,8 @@ fun Profile.Companion.stub( isBeta: Boolean = false, isGuest: Boolean = false, trackId: Long? = null, - projectId: Long? = null + projectId: Long? = null, + featuresMap: Map = emptyMap() ): Profile = Profile( id = id, @@ -35,5 +36,5 @@ fun Profile.Companion.stub( trackTitle = null, projectId = projectId, isBeta = isBeta, - featuresMap = emptyMap() + featuresMap = featuresMap ) \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/progress_screen/ProgressScreenTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/progress_screen/ProgressScreenTest.kt index adaf6d82bf..4181c6fabf 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/progress_screen/ProgressScreenTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/progress_screen/ProgressScreenTest.kt @@ -196,29 +196,33 @@ class ProgressScreenTest { } @Test - fun `User on freemium doesn't see applied topics and graduated projects`() { - val state = ProgressScreenFeature.State( - trackProgressState = ProgressScreenFeature.TrackProgressState.Content( - trackWithProgress = TrackWithProgress.stub(trackId = 1L, projects = listOf(1, 2, 3)), - studyPlan = StudyPlan.stub(), - profile = Profile.stub(), - subscription = Subscription.stub(type = SubscriptionType.FREEMIUM) - ), - projectProgressState = ProgressScreenFeature.ProjectProgressState.Empty, - isTrackProgressRefreshing = false, - isProjectProgressRefreshing = false - ) - - val viewState = viewStateMapper.map(state) + fun `User with freemium or mobile-only subscription doesn't see applied topics and graduated projects`() { + listOf( + SubscriptionType.FREEMIUM, + SubscriptionType.MOBILE_ONLY + ).forEach { subscriptionType -> + val state = ProgressScreenFeature.State( + trackProgressState = ProgressScreenFeature.TrackProgressState.Content( + trackWithProgress = TrackWithProgress.stub(trackId = 1L, projects = listOf(1, 2, 3)), + studyPlan = StudyPlan.stub(), + profile = Profile.stub(), + subscription = Subscription.stub(type = subscriptionType) + ), + projectProgressState = ProgressScreenFeature.ProjectProgressState.Empty, + isTrackProgressRefreshing = false, + isProjectProgressRefreshing = false + ) - val trackProgressContentState = viewState.trackProgressViewState as TrackProgressViewState.Content + val viewState = viewStateMapper.map(state) - assertEquals( - TrackProgressViewState.Content.AppliedTopicsState.Empty, - trackProgressContentState.appliedTopicsState - ) + val trackProgressContentState = viewState.trackProgressViewState as TrackProgressViewState.Content - assertNull(trackProgressContentState.completedGraduateProjectsCount) + assertEquals( + TrackProgressViewState.Content.AppliedTopicsState.Empty, + trackProgressContentState.appliedTopicsState + ) + assertNull(trackProgressContentState.completedGraduateProjectsCount) + } } @Test diff --git a/shared/src/commonTest/kotlin/org/hyperskill/projects_selection/ProjectsListTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/projects_selection/ProjectsListTest.kt index 948507eb32..ccab807885 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/projects_selection/ProjectsListTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/projects_selection/ProjectsListTest.kt @@ -9,7 +9,7 @@ import org.hyperskill.ResourceProviderStub import org.hyperskill.app.core.view.mapper.NumbersFormatter import org.hyperskill.app.core.view.mapper.date.SharedDateFormatter import org.hyperskill.app.profile.domain.model.Profile -import org.hyperskill.app.project_selection.list.domain.analytic.ProjectSelectionListClickedProjectHyperskillAnalyticsEvent +import org.hyperskill.app.project_selection.list.domain.analytic.ProjectSelectionListClickedProjectHyperskillAnalyticEvent import org.hyperskill.app.project_selection.list.presentation.ProjectSelectionListFeature import org.hyperskill.app.project_selection.list.presentation.ProjectSelectionListFeature.Action import org.hyperskill.app.project_selection.list.presentation.ProjectSelectionListFeature.ContentState @@ -182,7 +182,7 @@ class ProjectsListTest { assertTrue { actions.any { it is InternalAction.LogAnalyticEvent && - it.analyticEvent is ProjectSelectionListClickedProjectHyperskillAnalyticsEvent && + it.analyticEvent is ProjectSelectionListClickedProjectHyperskillAnalyticEvent && it.analyticEvent.projectId == projectId && it.analyticEvent.trackId == trackId } diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_completion/StepCompletionTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_completion/StepCompletionTest.kt index af42d5fe37..ae7bfaded6 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/step_completion/StepCompletionTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_completion/StepCompletionTest.kt @@ -1,11 +1,14 @@ package org.hyperskill.step_completion import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertTrue import org.hyperskill.app.step.domain.model.Step import org.hyperskill.app.step.domain.model.StepRoute import org.hyperskill.app.step_completion.presentation.StepCompletionFeature import org.hyperskill.app.step_completion.presentation.StepCompletionReducer +import org.hyperskill.app.subscriptions.domain.model.FreemiumChargeLimitsStrategy import org.hyperskill.step.domain.model.stub class StepCompletionTest { @@ -29,4 +32,70 @@ class StepCompletionTest { } } } + + @Test + fun `Not current step solved do nothing`() { + val stepRoute = StepRoute.LearnDaily(1L) + val initialState = StepCompletionFeature.createState(Step.stub(stepRoute.stepId), stepRoute) + + val reducer = StepCompletionReducer(stepRoute) + val (actualState, actualActions) = reducer.reduce( + initialState, + StepCompletionFeature.Message.StepSolved(2L) + ) + + assertEquals(initialState, actualState) + assertTrue(actualActions.isEmpty()) + } + + @Test + fun `Solved step with limited attempts updates problems limits`() { + val stepRoute = StepRoute.Learn.Step(1L) + val initialState = StepCompletionFeature.createState(Step.stub(stepRoute.stepId), stepRoute) + + val reducer = StepCompletionReducer(stepRoute) + val (actualState, actualActions) = reducer.reduce( + initialState, + StepCompletionFeature.Message.StepSolved(stepRoute.stepId) + ) + + assertEquals(initialState, actualState) + assertContains( + actualActions, + StepCompletionFeature.Action.UpdateProblemsLimit(FreemiumChargeLimitsStrategy.AFTER_CORRECT_SUBMISSION) + ) + } + + @Test + fun `Solved step without limited attempts not updates problems limits`() { + val stepRoute = StepRoute.LearnDaily(1L) + val initialState = StepCompletionFeature.createState(Step.stub(stepRoute.stepId), stepRoute) + + val reducer = StepCompletionReducer(stepRoute) + val (actualState, actualActions) = reducer.reduce( + initialState, + StepCompletionFeature.Message.StepSolved(stepRoute.stepId) + ) + + assertEquals(initialState, actualState) + assertTrue(actualActions.isEmpty()) + } + + @Test + fun `Solved interview preparation step marks it as solved`() { + val stepRoute = StepRoute.InterviewPreparation(1L) + val initialState = StepCompletionFeature.createState(Step.stub(stepRoute.stepId), stepRoute) + + val reducer = StepCompletionReducer(stepRoute) + val (actualState, actualActions) = reducer.reduce( + initialState, + StepCompletionFeature.Message.StepSolved(stepRoute.stepId) + ) + + assertEquals(initialState, actualState) + assertContains( + actualActions, + StepCompletionFeature.InternalAction.MarkInterviewStepAsSolved(stepRoute.stepId) + ) + } } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/StepQuizTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/StepQuizTest.kt index 123bf74679..3cee2bf992 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/StepQuizTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/step_quiz/StepQuizTest.kt @@ -1,6 +1,7 @@ package org.hyperskill.step_quiz import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertTrue import org.hyperskill.app.onboarding.domain.model.ProblemsOnboardingFlags @@ -8,10 +9,13 @@ import org.hyperskill.app.step.domain.model.Step import org.hyperskill.app.step.domain.model.StepRoute import org.hyperskill.app.step_quiz.domain.analytic.StepQuizClickedTheoryToolbarItemHyperskillAnalyticEvent import org.hyperskill.app.step_quiz.domain.model.attempts.Attempt +import org.hyperskill.app.step_quiz.domain.model.submissions.Submission +import org.hyperskill.app.step_quiz.domain.model.submissions.SubmissionStatus import org.hyperskill.app.step_quiz.presentation.StepQuizFeature import org.hyperskill.app.step_quiz.presentation.StepQuizReducer import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsFeature import org.hyperskill.app.step_quiz_hints.presentation.StepQuizHintsReducer +import org.hyperskill.app.subscriptions.domain.model.FreemiumChargeLimitsStrategy import org.hyperskill.step.domain.model.stub import org.hyperskill.step_quiz.domain.model.stub @@ -49,7 +53,11 @@ class StepQuizTest { attempt, submissionState, isProblemsLimitReached = true, - problemsLimitReachedModalText = "", + problemsLimitReachedModalData = StepQuizFeature.ProblemsLimitReachedModalData( + title = "", + description = "", + unlockLimitsButtonText = null + ), problemsOnboardingFlags = ProblemsOnboardingFlags( isParsonsOnboardingShown = false, isFillBlanksInputModeOnboardingShown = false, @@ -94,7 +102,11 @@ class StepQuizTest { attempt, submissionState, isProblemsLimitReached = true, - problemsLimitReachedModalText = "", + problemsLimitReachedModalData = StepQuizFeature.ProblemsLimitReachedModalData( + title = "", + description = "", + unlockLimitsButtonText = null + ), problemsOnboardingFlags = ProblemsOnboardingFlags( isParsonsOnboardingShown = false, isFillBlanksInputModeOnboardingShown = false, @@ -109,6 +121,192 @@ class StepQuizTest { } } + @Test + fun `Receiving wrong submission for step with limited attempts updates problems limits`() { + val step = Step.stub(id = 1) + val initialState = StepQuizFeature.State( + stepQuizState = StepQuizFeature.StepQuizState.AttemptLoaded( + step = step, + attempt = Attempt.stub(), + submissionState = StepQuizFeature.SubmissionState.Empty(), + isProblemsLimitReached = false, + isTheoryAvailable = false + ), + stepQuizHintsState = StepQuizHintsFeature.State.Idle + ) + + val reducer = StepQuizReducer( + stepRoute = StepRoute.Learn.Step(step.id), + stepQuizHintsReducer = StepQuizHintsReducer(StepRoute.Learn.Step(step.id)) + ) + + val (actualState, actualActions) = reducer.reduce( + initialState, + StepQuizFeature.Message.CreateSubmissionSuccess(Submission.stub(status = SubmissionStatus.WRONG)) + ) + + val expectedState = StepQuizFeature.State( + stepQuizState = StepQuizFeature.StepQuizState.AttemptLoaded( + step = step, + attempt = Attempt.stub(), + submissionState = StepQuizFeature.SubmissionState.Loaded( + Submission.stub(status = SubmissionStatus.WRONG) + ), + isProblemsLimitReached = false, + isTheoryAvailable = false + ), + stepQuizHintsState = StepQuizHintsFeature.State.Idle + ) + + assertEquals(expectedState, actualState) + assertContains( + actualActions, + StepQuizFeature.InternalAction.UpdateProblemsLimit(FreemiumChargeLimitsStrategy.AFTER_WRONG_SUBMISSION) + ) + } + + @Test + fun `Receiving wrong submission for step without limited attempts not updates problems limits`() { + val step = Step.stub(id = 1) + val initialState = StepQuizFeature.State( + stepQuizState = StepQuizFeature.StepQuizState.AttemptLoaded( + step = step, + attempt = Attempt.stub(), + submissionState = StepQuizFeature.SubmissionState.Empty(), + isProblemsLimitReached = false, + isTheoryAvailable = false + ), + stepQuizHintsState = StepQuizHintsFeature.State.Idle + ) + + val reducer = StepQuizReducer( + stepRoute = StepRoute.LearnDaily(step.id), + stepQuizHintsReducer = StepQuizHintsReducer(StepRoute.LearnDaily(step.id)) + ) + + val (actualState, actualActions) = reducer.reduce( + initialState, + StepQuizFeature.Message.CreateSubmissionSuccess(Submission.stub(status = SubmissionStatus.WRONG)) + ) + + val expectedState = StepQuizFeature.State( + stepQuizState = StepQuizFeature.StepQuizState.AttemptLoaded( + step = step, + attempt = Attempt.stub(), + submissionState = StepQuizFeature.SubmissionState.Loaded( + Submission.stub(status = SubmissionStatus.WRONG) + ), + isProblemsLimitReached = false, + isTheoryAvailable = false + ), + stepQuizHintsState = StepQuizHintsFeature.State.Idle + ) + + assertEquals(expectedState, actualState) + assertTrue(actualActions.isEmpty()) + } + + @Test + fun `When updated problems limits reached for step with limited attempts blocks solving`() { + val step = Step.stub(id = 1) + val initialState = StepQuizFeature.State( + stepQuizState = StepQuizFeature.StepQuizState.AttemptLoaded( + step = step, + attempt = Attempt.stub(), + submissionState = StepQuizFeature.SubmissionState.Empty(), + isProblemsLimitReached = false, + isTheoryAvailable = false + ), + stepQuizHintsState = StepQuizHintsFeature.State.Idle + ) + + val reducer = StepQuizReducer( + stepRoute = StepRoute.Learn.Step(step.id), + stepQuizHintsReducer = StepQuizHintsReducer(StepRoute.Learn.Step(step.id)) + ) + + val (actualState, actualActions) = reducer.reduce( + initialState, + StepQuizFeature.InternalMessage.UpdateProblemsLimitResult( + isProblemsLimitReached = true, + problemsLimitReachedModalData = StepQuizFeature.ProblemsLimitReachedModalData( + title = "", + description = "", + unlockLimitsButtonText = null + ) + ) + ) + + val expectedState = StepQuizFeature.State( + stepQuizState = StepQuizFeature.StepQuizState.AttemptLoaded( + step = step, + attempt = Attempt.stub(), + submissionState = StepQuizFeature.SubmissionState.Empty(), + isProblemsLimitReached = true, + isTheoryAvailable = false + ), + stepQuizHintsState = StepQuizHintsFeature.State.Idle + ) + + assertEquals(expectedState, actualState) + assertContains( + actualActions, + StepQuizFeature.Action.ViewAction.ShowProblemsLimitReachedModal( + StepQuizFeature.ProblemsLimitReachedModalData( + title = "", + description = "", + unlockLimitsButtonText = null + ) + ) + ) + } + + @Test + fun `When updated problems limits reached for step without limited attempts not blocks solving`() { + val step = Step.stub(id = 1) + val initialState = StepQuizFeature.State( + stepQuizState = StepQuizFeature.StepQuizState.AttemptLoaded( + step = step, + attempt = Attempt.stub(), + submissionState = StepQuizFeature.SubmissionState.Empty(), + isProblemsLimitReached = false, + isTheoryAvailable = false + ), + stepQuizHintsState = StepQuizHintsFeature.State.Idle + ) + + val reducer = StepQuizReducer( + stepRoute = StepRoute.LearnDaily(step.id), + stepQuizHintsReducer = StepQuizHintsReducer(StepRoute.LearnDaily(step.id)) + ) + + val (actualState, actualActions) = reducer.reduce( + initialState, + StepQuizFeature.InternalMessage.UpdateProblemsLimitResult( + isProblemsLimitReached = true, + problemsLimitReachedModalData = StepQuizFeature.ProblemsLimitReachedModalData( + title = "", + description = "", + unlockLimitsButtonText = null + ) + ) + ) + + val expectedState = StepQuizFeature.State( + stepQuizState = StepQuizFeature.StepQuizState.AttemptLoaded( + step = step, + attempt = Attempt.stub(), + submissionState = StepQuizFeature.SubmissionState.Empty(), + isProblemsLimitReached = false, + isTheoryAvailable = false + ), + stepQuizHintsState = StepQuizHintsFeature.State.Idle + ) + + assertEquals(expectedState, actualState) + assertTrue(actualActions.isEmpty()) + } + @Test fun `TheoryToolbarClicked message navigates to step screen when theory available`() { val topicTheoryId = 2L @@ -142,7 +340,7 @@ class StepQuizTest { attempt, submissionState, isProblemsLimitReached = false, - problemsLimitReachedModalText = null, + problemsLimitReachedModalData = null, problemsOnboardingFlags = ProblemsOnboardingFlags( isParsonsOnboardingShown = false, isFillBlanksInputModeOnboardingShown = false, @@ -204,7 +402,7 @@ class StepQuizTest { attempt, submissionState, isProblemsLimitReached = false, - problemsLimitReachedModalText = null, + problemsLimitReachedModalData = null, problemsOnboardingFlags = ProblemsOnboardingFlags( isParsonsOnboardingShown = false, isFillBlanksInputModeOnboardingShown = false, diff --git a/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenTest.kt index 93fb1ec329..7a574a43a3 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/study_plan/screen/StudyPlanScreenTest.kt @@ -17,11 +17,14 @@ import org.hyperskill.app.study_plan.screen.presentation.StudyPlanScreenFeature import org.hyperskill.app.study_plan.screen.presentation.StudyPlanScreenReducer import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetFeature import org.hyperskill.app.study_plan.widget.presentation.StudyPlanWidgetReducer +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetFeature +import org.hyperskill.app.users_questionnaire.widget.presentation.UsersQuestionnaireWidgetReducer class StudyPlanScreenTest { private val reducer = StudyPlanScreenReducer( GamificationToolbarReducer(GamificationToolbarScreen.STUDY_PLAN), ProblemsLimitReducer(ProblemsLimitScreen.STUDY_PLAN), + UsersQuestionnaireWidgetReducer(), StudyPlanWidgetReducer() ) @@ -60,6 +63,7 @@ class StudyPlanScreenTest { val expectedState = stubState( toolbarState = GamificationToolbarFeature.State.Loading, problemsLimitState = ProblemsLimitFeature.State.Loading, + questionnaireWidgetState = UsersQuestionnaireWidgetFeature.State.Loading, studyPlanWidgetState = StudyPlanWidgetFeature.State( sectionsStatus = StudyPlanWidgetFeature.ContentStatus.LOADING ) @@ -76,7 +80,13 @@ class StudyPlanScreenTest { private fun stubState( toolbarState: GamificationToolbarFeature.State = GamificationToolbarFeature.State.Idle, problemsLimitState: ProblemsLimitFeature.State = ProblemsLimitFeature.State.Idle, + questionnaireWidgetState: UsersQuestionnaireWidgetFeature.State = UsersQuestionnaireWidgetFeature.State.Idle, studyPlanWidgetState: StudyPlanWidgetFeature.State = StudyPlanWidgetFeature.State() ): StudyPlanScreenFeature.State = - StudyPlanScreenFeature.State(toolbarState, problemsLimitState, studyPlanWidgetState) + StudyPlanScreenFeature.State( + toolbarState, + problemsLimitState, + questionnaireWidgetState, + studyPlanWidgetState + ) } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/subscriptions/SubscriptionSerializationTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/subscriptions/SubscriptionSerializationTest.kt new file mode 100644 index 0000000000..0f99a7fb59 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/subscriptions/SubscriptionSerializationTest.kt @@ -0,0 +1,40 @@ +package org.hyperskill.subscriptions + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.datetime.Instant +import org.hyperskill.app.network.injection.NetworkModule +import org.hyperskill.app.subscriptions.domain.model.Subscription +import org.hyperskill.app.subscriptions.domain.model.SubscriptionStatus +import org.hyperskill.app.subscriptions.domain.model.SubscriptionType + +class SubscriptionSerializationTest { + companion object { + private const val TEST_JSON = """ + { + "type": "freemium", + "steps_limit_total": 10, + "steps_limit_left": 9, + "steps_limit_reset_time": "2022-11-16T12:19:14.782644Z", + "valid_till": "2099-01-01T01:00:00Z" + } + """ + + private val EXPECTED_SUBSCRIPTION: Subscription = + Subscription( + type = SubscriptionType.FREEMIUM, + status = SubscriptionStatus.ACTIVE, + stepsLimitTotal = 10, + stepsLimitLeft = 9, + stepsLimitResetTime = Instant.parse("2022-11-16T12:19:14.782644Z"), + validTill = Instant.parse("2099-01-01T01:00:00Z") + ) + } + + @Test + fun `Serialized subscription should be deserialized normally`() { + val json = NetworkModule.provideJson() + val actualModel = json.decodeFromString(Subscription.serializer(), TEST_JSON) + assertEquals(EXPECTED_SUBSCRIPTION, actualModel) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/subscriptions/SubscriptionStub.kt b/shared/src/commonTest/kotlin/org/hyperskill/subscriptions/SubscriptionStub.kt index c2c3dc9a8f..80a13ceae5 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/subscriptions/SubscriptionStub.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/subscriptions/SubscriptionStub.kt @@ -1,14 +1,18 @@ package org.hyperskill.subscriptions import org.hyperskill.app.subscriptions.domain.model.Subscription +import org.hyperskill.app.subscriptions.domain.model.SubscriptionStatus import org.hyperskill.app.subscriptions.domain.model.SubscriptionType fun Subscription.Companion.stub( - type: SubscriptionType = SubscriptionType.PREMIUM + type: SubscriptionType = SubscriptionType.PREMIUM, + status: SubscriptionStatus = SubscriptionStatus.ACTIVE ): Subscription = Subscription( type = type, + status = status, stepsLimitLeft = null, stepsLimitTotal = null, - stepsLimitResetTime = null + stepsLimitResetTime = null, + validTill = null ) \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/track_selection/details/TrackSelectionDetailsTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/track_selection/details/TrackSelectionDetailsTest.kt index fdcee625e7..4e78f95f5b 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/track_selection/details/TrackSelectionDetailsTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/track_selection/details/TrackSelectionDetailsTest.kt @@ -263,27 +263,32 @@ class TrackSelectionDetailsTest { } @Test - fun `Certificate and projects info should not be available for freemium user`() { - val state = TrackSelectionDetailsFeature.State( - trackWithProgress = TrackWithProgress.stub(), - isTrackSelected = false, - isNewUserMode = false, - isTrackLoadingShowed = false, - contentState = ContentState.Content( - providers = emptyList(), - profile = Profile.stub(), - subscriptionType = SubscriptionType.FREEMIUM + fun `Certificate and projects info should not be available for freemium or mobile-only user`() { + listOf( + SubscriptionType.FREEMIUM, + SubscriptionType.MOBILE_ONLY + ).forEach { subscriptionType -> + val state = TrackSelectionDetailsFeature.State( + trackWithProgress = TrackWithProgress.stub(), + isTrackSelected = false, + isNewUserMode = false, + isTrackLoadingShowed = false, + contentState = ContentState.Content( + providers = emptyList(), + profile = Profile.stub(), + subscriptionType = subscriptionType + ) ) - ) - val viewState = viewStateMapper.map(state) + val viewState = viewStateMapper.map(state) - assertFalse { - (viewState as ViewState.Content).isCertificateAvailable + assertFalse { + (viewState as ViewState.Content).isCertificateAvailable + } + assertNull( + (viewState as ViewState.Content).formattedProjectsCount + ) } - assertNull( - (viewState as ViewState.Content).formattedProjectsCount - ) } @Test diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/application_shortcuts/domain/analytic/ApplicationShortcutItemClickedHyperskillAnalyticEvent.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/application_shortcuts/domain/analytic/ApplicationShortcutItemClickedHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..300cc019f6 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/application_shortcuts/domain/analytic/ApplicationShortcutItemClickedHyperskillAnalyticEvent.kt @@ -0,0 +1,47 @@ +package org.hyperskill.app.application_shortcuts.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents click on the home screen quick action analytic event. + * + * JSON payload: + * ``` + * { + * "route": "SpringBoard", + * "action": "click", + * "part": "main", + * "target": "home_screen_quick_action", + * "context": + * { + * "type": "org.hyperskill.App.SendFeedback" + * } + * } + * ``` + * + * @property shortcutItemIdentifier The identifier of the clicked quick action. + * @see HyperskillAnalyticEvent + */ +class ApplicationShortcutItemClickedHyperskillAnalyticEvent( + private val shortcutItemIdentifier: String +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.IosSpringBoard(), + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.HOME_SCREEN_QUICK_ACTION +) { + companion object { + private const val PARAM_TYPE = "type" + } + + override val params: Map + get() = super.params + mapOf( + PARAM_CONTEXT to mapOf( + PARAM_TYPE to shortcutItemIdentifier + ) + ) +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/application_shortcuts/domain/interactor/ApplicationShortcutsInteractor.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/application_shortcuts/domain/interactor/ApplicationShortcutsInteractor.kt new file mode 100644 index 0000000000..2400cdd0cc --- /dev/null +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/application_shortcuts/domain/interactor/ApplicationShortcutsInteractor.kt @@ -0,0 +1,30 @@ +package org.hyperskill.app.application_shortcuts.domain.interactor + +import org.hyperskill.app.SharedResources +import org.hyperskill.app.core.domain.platform.Platform +import org.hyperskill.app.core.remote.UserAgentInfo +import org.hyperskill.app.core.view.mapper.ResourceProvider +import org.hyperskill.app.profile.domain.repository.CurrentProfileStateRepository +import org.hyperskill.app.profile_settings.domain.model.FeedbackEmailData +import org.hyperskill.app.profile_settings.domain.model.FeedbackEmailDataBuilder + +class ApplicationShortcutsInteractor( + private val currentProfileStateRepository: CurrentProfileStateRepository, + private val platform: Platform, + private val userAgentInfo: UserAgentInfo, + private val resourceProvider: ResourceProvider +) { + suspend fun getSendFeedbackEmailData(): FeedbackEmailData { + val currentProfile = currentProfileStateRepository + .getState() + .getOrNull() + + return FeedbackEmailDataBuilder.build( + supportEmail = resourceProvider.getString(SharedResources.strings.settings_send_feedback_support_email), + applicationName = resourceProvider.getString(platform.appNameResource), + platform = platform, + userId = currentProfile?.id, + applicationVersion = userAgentInfo.versionCode + ) + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/application_shortcuts/injection/ApplicationShortcutsDataComponent.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/application_shortcuts/injection/ApplicationShortcutsDataComponent.kt new file mode 100644 index 0000000000..4ee77d3ea2 --- /dev/null +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/application_shortcuts/injection/ApplicationShortcutsDataComponent.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.application_shortcuts.injection + +import org.hyperskill.app.application_shortcuts.domain.interactor.ApplicationShortcutsInteractor + +interface ApplicationShortcutsDataComponent { + val applicationShortcutsInteractor: ApplicationShortcutsInteractor +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/application_shortcuts/injection/ApplicationShortcutsDataComponentImpl.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/application_shortcuts/injection/ApplicationShortcutsDataComponentImpl.kt new file mode 100644 index 0000000000..33aa7fe56e --- /dev/null +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/application_shortcuts/injection/ApplicationShortcutsDataComponentImpl.kt @@ -0,0 +1,16 @@ +package org.hyperskill.app.application_shortcuts.injection + +import org.hyperskill.app.application_shortcuts.domain.interactor.ApplicationShortcutsInteractor +import org.hyperskill.app.core.injection.AppGraph + +internal class ApplicationShortcutsDataComponentImpl( + private val appGraph: AppGraph +) : ApplicationShortcutsDataComponent { + override val applicationShortcutsInteractor: ApplicationShortcutsInteractor + get() = ApplicationShortcutsInteractor( + currentProfileStateRepository = appGraph.profileDataComponent.currentProfileStateRepository, + platform = appGraph.commonComponent.platform, + userAgentInfo = appGraph.commonComponent.userAgentInfo, + resourceProvider = appGraph.commonComponent.resourceProvider + ) +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/core/domain/platform/Platform.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/core/domain/platform/Platform.kt index bc020ed73f..fedb2643b1 100644 --- a/shared/src/iosMain/kotlin/org/hyperskill/app/core/domain/platform/Platform.kt +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/core/domain/platform/Platform.kt @@ -14,4 +14,6 @@ actual class Platform actual constructor() { actual val feedbackName: String = "iOS" actual val appNameResource: StringResource = SharedResources.strings.ios_app_name + + actual val isSubscriptionPurchaseEnabled: Boolean = false } \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/core/injection/IosAppComponent.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/core/injection/IosAppComponent.kt index a4addb3af5..344cd1ca26 100644 --- a/shared/src/iosMain/kotlin/org/hyperskill/app/core/injection/IosAppComponent.kt +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/core/injection/IosAppComponent.kt @@ -1,7 +1,10 @@ package org.hyperskill.app.core.injection +import org.hyperskill.app.application_shortcuts.injection.ApplicationShortcutsDataComponent import org.hyperskill.app.notification.remote.data.repository.IosFCMTokenProvider interface IosAppComponent : AppGraph { fun getIosFCMTokenProvider(): IosFCMTokenProvider + + fun buildApplicationShortcutsDataComponent(): ApplicationShortcutsDataComponent } \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/core/injection/IosAppComponentImpl.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/core/injection/IosAppComponentImpl.kt index b2561d18d6..8ff9b658da 100644 --- a/shared/src/iosMain/kotlin/org/hyperskill/app/core/injection/IosAppComponentImpl.kt +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/core/injection/IosAppComponentImpl.kt @@ -3,10 +3,15 @@ package org.hyperskill.app.core.injection import org.hyperskill.app.analytic.domain.model.AnalyticEngine import org.hyperskill.app.analytic.injection.AnalyticComponent import org.hyperskill.app.analytic.injection.AnalyticComponentImpl +import org.hyperskill.app.application_shortcuts.injection.ApplicationShortcutsDataComponent +import org.hyperskill.app.application_shortcuts.injection.ApplicationShortcutsDataComponentImpl import org.hyperskill.app.core.domain.BuildVariant import org.hyperskill.app.core.remote.UserAgentInfo import org.hyperskill.app.notification.remote.injection.IosPlatformPushNotificationsDataComponent import org.hyperskill.app.notification.remote.injection.PlatformPushNotificationsDataComponent +import org.hyperskill.app.purchase.domain.model.IOSPurchaseManager +import org.hyperskill.app.purchases.injection.PurchaseComponent +import org.hyperskill.app.purchases.injection.PurchaseComponentImpl import org.hyperskill.app.sentry.domain.model.manager.SentryManager import org.hyperskill.app.sentry.injection.SentryComponent import org.hyperskill.app.sentry.injection.SentryComponentImpl @@ -37,4 +42,10 @@ abstract class IosAppComponentImpl( IosPlatformPushNotificationsDataComponent( iosFCMTokenProvider = getIosFCMTokenProvider() ) + + override fun buildPurchaseComponent(): PurchaseComponent = + PurchaseComponentImpl(IOSPurchaseManager()) + + override fun buildApplicationShortcutsDataComponent(): ApplicationShortcutsDataComponent = + ApplicationShortcutsDataComponentImpl(this) } \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/purchase/domain/model/IOSPurchaseManager.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/purchase/domain/model/IOSPurchaseManager.kt new file mode 100644 index 0000000000..a7ebdfb8ea --- /dev/null +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/purchase/domain/model/IOSPurchaseManager.kt @@ -0,0 +1,27 @@ +package org.hyperskill.app.purchase.domain.model + +import org.hyperskill.app.purchases.domain.model.PlatformPurchaseParams +import org.hyperskill.app.purchases.domain.model.PurchaseManager +import org.hyperskill.app.purchases.domain.model.PurchaseResult + +// TODO: ALTAPPS-1110 +class IOSPurchaseManager : PurchaseManager { + override fun isConfigured(): Boolean = false + + override fun configure(userId: Long) {} + + override suspend fun login(userId: Long): Result = + Result.failure(IllegalStateException("iOS platform not supports purchases")) + + override suspend fun purchase( + productId: String, + platformPurchaseParams: PlatformPurchaseParams + ): Result = + Result.failure(IllegalStateException("iOS platform not supports purchases")) + + override suspend fun getManagementUrl(): Result = + Result.failure(IllegalStateException("iOS platform not supports purchases")) + + override suspend fun getFormattedProductPrice(productId: String): Result = + Result.failure(IllegalStateException("iOS platform not supports purchases")) +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/org/hyperskill/app/purchase/domain/model/IOSPurchaseParams.kt b/shared/src/iosMain/kotlin/org/hyperskill/app/purchase/domain/model/IOSPurchaseParams.kt new file mode 100644 index 0000000000..e3c312af8d --- /dev/null +++ b/shared/src/iosMain/kotlin/org/hyperskill/app/purchase/domain/model/IOSPurchaseParams.kt @@ -0,0 +1,5 @@ +package org.hyperskill.app.purchase.domain.model + +import org.hyperskill.app.purchases.domain.model.PlatformPurchaseParams + +object IOSPurchaseParams : PlatformPurchaseParams \ No newline at end of file