diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml index 9e92486849b..1f293b1282b 100644 --- a/.github/workflows/build_tests.yml +++ b/.github/workflows/build_tests.yml @@ -10,6 +10,10 @@ on: # Push events on develop branch - develop +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: bazel_build_app: name: Build Binary with Bazel diff --git a/.github/workflows/code_coverage.yml b/.github/workflows/code_coverage.yml new file mode 100644 index 00000000000..711151743b2 --- /dev/null +++ b/.github/workflows/code_coverage.yml @@ -0,0 +1,255 @@ +# Contains jobs corresponding to code coverage. + +name: Code Coverage + +# Controls when the action will run. Triggers the workflow on pull request +# events or push events in the develop branch. +on: + pull_request: + push: + branches: + # Push events on develop branch + - develop + +jobs: + check_unit_tests_completed: + name: Check unit test completed + runs-on: ubuntu-latest + steps: + - name: Wait for unit tests to checks + uses: ArcticLampyrid/action-wait-for-workflow@v1.2.0 + with: + workflow: unit_tests.yml + sha: auto + + compute_changed_files: + name: Compute changed files + needs: check_unit_tests_completed + runs-on: ubuntu-20.04 + outputs: + matrix: ${{ steps.compute-file-matrix.outputs.matrix }} + can_skip_files: ${{ steps.compute-file-matrix.outputs.can_skip_files }} + env: + CACHE_DIRECTORY: ~/.bazel_cache + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Bazel + uses: abhinavsingh/setup-bazel@v3 + with: + version: 6.5.0 + + - uses: actions/cache@v2 + id: scripts_cache + with: + path: ${{ env.CACHE_DIRECTORY }} + key: ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-scripts-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-scripts- + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel- + + # This check is needed to ensure that Bazel's unbounded cache growth doesn't result in a + # situation where the cache never updates (e.g. due to exceeding GitHub's cache size limit) + # thereby only ever using the last successful cache version. This solution will result in a + # few slower CI actions around the time cache is detected to be too large, but it should + # incrementally improve thereafter. + - name: Ensure cache size + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + # See https://stackoverflow.com/a/27485157 for reference. + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + CACHE_SIZE_MB=$(du -smc $EXPANDED_BAZEL_CACHE_PATH | grep total | cut -f1) + echo "Total size of Bazel cache (rounded up to MBs): $CACHE_SIZE_MB" + # Use a 4.5GB threshold since actions/cache compresses the results, and Bazel caches seem + # to only increase by a few hundred megabytes across changes for unrelated branches. This + # is also a reasonable upper-bound (local tests as of 2021-03-31 suggest that a full build + # of the codebase (e.g. //...) from scratch only requires a ~2.1GB uncompressed/~900MB + # compressed cache). + if [[ "$CACHE_SIZE_MB" -gt 4500 ]]; then + echo "Cache exceeds cut-off; resetting it (will result in a slow build)" + rm -rf $EXPANDED_BAZEL_CACHE_PATH + fi + + - name: Configure Bazel to use a local cache + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + echo "Using $EXPANDED_BAZEL_CACHE_PATH as Bazel's cache path" + echo "build --disk_cache=$EXPANDED_BAZEL_CACHE_PATH" >> $HOME/.bazelrc + shell: bash + + - name: Compute file matrix + id: compute-file-matrix + env: + compute_all_files: ${{ contains(github.event.pull_request.title, '[RunAllTests]') }} + # See: https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads#pull_request. Defer to origin/develop outside a PR (such as develop's main CI runs). + base_commit_hash: ${{ github.event.pull_request.base.sha || 'origin/develop' }} + # https://unix.stackexchange.com/a/338124 for reference on creating a JSON-friendly + # comma-separated list of test targets for the matrix. + run: | + bazel run //scripts:compute_changed_files -- $(pwd) $(pwd)/changed_files.log $base_commit_hash compute_all_files=$compute_all_files + FILE_BUCKET_LIST=$(cat ./changed_files.log | sed 's/^\|$/"/g' | paste -sd, -) + echo "Changed files (note that this might be all files if configured to run all or on the develop branch): $FILE_BUCKET_LIST" + echo "::set-output name=matrix::{\"changed-files-bucket-base64-encoded-shard\":[$FILE_BUCKET_LIST]}" + if [[ ! -z "$FILE_BUCKET_LIST" ]]; then + echo "::set-output name=can_skip_files::false" + else + echo "::set-output name=can_skip_files::true" + echo "No files are affected by this change. If this is wrong, you can add '[RunAllTests]' to the PR title to force a run." + fi + + code_coverage_run: + name: Run Code Coverage + needs: compute_changed_files + if: ${{ needs.compute_changed_files.outputs.can_skip_files != 'true' }} + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + max-parallel: 10 + matrix: ${{ fromJson(needs.compute_changed_files.outputs.matrix) }} + env: + CACHE_DIRECTORY: ~/.bazel_cache + steps: + - uses: actions/checkout@v2 + + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + + - name: Set up Bazel + uses: abhinavsingh/setup-bazel@v3 + with: + version: 6.5.0 + + - uses: actions/cache@v2 + id: scripts_cache + with: + path: ${{ env.CACHE_DIRECTORY }} + key: ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-scripts-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-scripts- + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel- + + - name: Set up build environment + uses: ./.github/actions/set-up-android-bazel-build-environment + + - name: Configure Bazel to use a local cache (for scripts) + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + echo "Using $EXPANDED_BAZEL_CACHE_PATH as Bazel's cache path" + echo "build --disk_cache=$EXPANDED_BAZEL_CACHE_PATH" >> $HOME/.bazelrc + shell: bash + + - name: Extract caching bucket + env: + CHANGED_FILESS_BUCKET_BASE64_ENCODED_SHARD: ${{ matrix.changed-files-bucket-base64-encoded-shard }} + run: | + # See https://stackoverflow.com/a/29903172 for cut logic. This is needed to remove the + # user-friendly shard prefix from the matrix value. + CHANGED_FILES_BUCKET_BASE64=$(echo "$CHANGED_FILESS_BUCKET_BASE64_ENCODED_SHARD" | cut -d ";" -f 2) + bazel run //scripts:retrieve_changed_files -- $(pwd) $CHANGED_FILES_BUCKET_BASE64 $(pwd)/file_bucket_name $(pwd)/changed_files $(pwd)/bazel_test_targets + FILE_CATEGORY=$(cat ./file_bucket_name) + CHANGED_FILES=$(cat ./changed_files) + BAZEL_TEST_TARGETS=$(cat ./bazel_test_targets) + echo "File category: $FILE_CATEGORY" + echo "Changed Files: $CHANGED_FILES" + echo "Bazel test targets: $BAZEL_TEST_TARGETS" + echo "FILE_CACHING_BUCKET=$FILE_CATEGORY" >> $GITHUB_ENV + echo "CHANGED_FILES=$CHANGED_FILES" >> $GITHUB_ENV + echo "BAZEL_TEST_TARGETS=$BAZEL_TEST_TARGETS" >> $GITHUB_ENV + + # For reference on this & the later cache actions, see: + # https://github.com/actions/cache/issues/239#issuecomment-606950711 & + # https://github.com/actions/cache/issues/109#issuecomment-558771281. Note that these work + # with Bazel since Bazel can share the most recent cache from an unrelated build and still + # benefit from incremental build performance (assuming that actions/cache aggressively removes + # older caches due to the 5GB cache limit size & Bazel's large cache size). + - uses: actions/cache@v2 + id: test_cache + with: + path: ${{ env.CACHE_DIRECTORY }} + key: ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-tests-${{ env.FILE_CACHING_BUCKET }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-tests-${{ env.FILE_CACHING_BUCKET }}- + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-tests- + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-binary- + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel- + + # This check is needed to ensure that Bazel's unbounded cache growth doesn't result in a + # situation where the cache never updates (e.g. due to exceeding GitHub's cache size limit) + # thereby only ever using the last successful cache version. This solution will result in a + # few slower CI actions around the time cache is detected to be too large, but it should + # incrementally improve thereafter. + - name: Ensure cache size + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + # See https://stackoverflow.com/a/27485157 for reference. + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + CACHE_SIZE_MB=$(du -smc $EXPANDED_BAZEL_CACHE_PATH | grep total | cut -f1) + echo "Total size of Bazel cache (rounded up to MBs): $CACHE_SIZE_MB" + # Use a 4.5GB threshold since actions/cache compresses the results, and Bazel caches seem + # to only increase by a few hundred megabytes across changes for unrelated branches. This + # is also a reasonable upper-bound (local tests as of 2021-03-31 suggest that a full build + # of the codebase (e.g. //...) from scratch only requires a ~2.1GB uncompressed/~900MB + # compressed cache). + if [[ "$CACHE_SIZE_MB" -gt 4500 ]]; then + echo "Cache exceeds cut-off; resetting it (will result in a slow build)" + rm -rf $EXPANDED_BAZEL_CACHE_PATH + fi + + - name: Configure Bazel to use a local cache + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + echo "Using $EXPANDED_BAZEL_CACHE_PATH as Bazel's cache path" + echo "build --disk_cache=$EXPANDED_BAZEL_CACHE_PATH" >> $HOME/.bazelrc + shell: bash + + - name: Build Oppia Tests + env: + BAZEL_TEST_TARGETS: ${{ env.BAZEL_TEST_TARGETS }} + run: | + # Attempt to build 5 times in case there are flaky builds. + # TODO(#3759): Remove this once there are no longer app test build failures. + i=0 + # Disable exit-on-first-failure. + set +e + while [ $i -ne 5 ]; do + i=$(( $i+1 )) + echo "Attempt $i/5 to build test targets" + bazel build --keep_going -- $BAZEL_TEST_TARGETS + done + # Capture the error code of the final command run (which should be a success if there isn't a real build failure). + last_error_code=$? + # Reenable exit-on-first-failure. + set -e + # Exit only if the most recent exit was a failure (by using a subshell). + (exit $last_error_code) + + - name: Run Oppia Coverage + env: + CHANGED_FILES: ${{ env.CHANGED_FILES }} + run: | + bazel run //scripts:run_coverage -- $(pwd) $CHANGED_FILES --format=MARKDOWN --processTimeout=15 + + # Reference: https://github.community/t/127354/7. + check_coverage_results: + name: Check Code Coverage Results + needs: [ compute_changed_files, code_coverage_run ] + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} + runs-on: ubuntu-20.04 + steps: + - name: Check coverages passed + if: ${{ needs.compute_changed_files.outputs.can_skip_files != 'true' && needs.code_coverage_run.result != 'success' }} + run: exit 1 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index df9444d9da0..ed6cd752264 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,6 +12,10 @@ on: # Push events on develop branch - develop +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + # This workflow has the following jobs: # robolectric_tests: Robolectric tests for all modules except the app module # app_tests: Non-flaky Robolectric tests for the app module @@ -54,45 +58,53 @@ jobs: run: sudo ./gradlew --full-stacktrace assembleDebug -Dorg.gradle.java.home=$JAVA_HOME - name: Utility tests - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} # We require 'sudo' to avoid an error of the existing android sdk. See https://github.com/actions/starter-workflows/issues/58 run: sudo ./gradlew --full-stacktrace :utility:testDebugUnitTest -Dorg.gradle.java.home=$JAVA_HOME - name: Upload Utility Test Reports uses: actions/upload-artifact@v2 - if: ${{ always() }} # IMPORTANT: Upload reports regardless of status + if: ${{ !cancelled() }} # IMPORTANT: Upload reports regardless of success or failure status with: name: utility reports path: utility/build/reports - name: Data tests - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} # We require 'sudo' to avoid an error of the existing android sdk. See https://github.com/actions/starter-workflows/issues/58 run: sudo ./gradlew --full-stacktrace :data:testDebugUnitTest -Dorg.gradle.java.home=$JAVA_HOME - name: Upload Data Test Reports uses: actions/upload-artifact@v2 - if: ${{ always() }} # IMPORTANT: Upload reports regardless of status + if: ${{ !cancelled() }} # IMPORTANT: Upload reports regardless of success or failure status with: name: data reports path: data/build/reports - name: Domain tests - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} # We require 'sudo' to avoid an error of the existing android sdk. See https://github.com/actions/starter-workflows/issues/58 run: sudo ./gradlew --full-stacktrace :domain:testDebugUnitTest -Dorg.gradle.java.home=$JAVA_HOME - name: Upload Domain Test Reports uses: actions/upload-artifact@v2 - if: ${{ always() }} # IMPORTANT: Upload reports regardless of status + if: ${{ !cancelled() }} # IMPORTANT: Upload reports regardless of success or failure status with: name: domain reports path: domain/build/reports - name: Testing tests - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} # We require 'sudo' to avoid an error of the existing android sdk. See https://github.com/actions/starter-workflows/issues/58 run: sudo ./gradlew --full-stacktrace :testing:testDebugUnitTest -Dorg.gradle.java.home=$JAVA_HOME - name: Upload Testing Test Reports uses: actions/upload-artifact@v2 - if: ${{ always() }} # IMPORTANT: Upload reports regardless of status + if: ${{ !cancelled() }} # IMPORTANT: Upload reports regardless of success or failure status with: name: testing reports path: testing/build/reports @@ -133,7 +145,7 @@ jobs: sudo ./gradlew --full-stacktrace :app:testDebugUnitTest --${{ matrix.shard }} -Dorg.gradle.java.home=$JAVA_HOME - name: Upload App Test Reports uses: actions/upload-artifact@v2 - if: ${{ always() }} # IMPORTANT: Upload reports regardless of status + if: ${{ !cancelled() }} # IMPORTANT: Upload reports regardless of success or failure status with: name: app reports ${{ matrix.shard }} path: app/build/reports @@ -141,7 +153,9 @@ jobs: app_tests: name: App Module Robolectric Tests needs: run_app_module_test - if: ${{ always() }} + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} runs-on: ${{ matrix.os }} strategy: matrix: diff --git a/.github/workflows/static_checks.yml b/.github/workflows/static_checks.yml index b312a43f067..1f9500c1dfc 100644 --- a/.github/workflows/static_checks.yml +++ b/.github/workflows/static_checks.yml @@ -9,6 +9,10 @@ on: branches: - develop +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: check_codeowners: name: Check CODEOWNERS & Repository files @@ -84,6 +88,10 @@ jobs: run: | bash /home/runner/work/oppia-android/oppia-android/scripts/ktlint_lint_check.sh $HOME + - name: Feature flag checks + run: | + bash /home/runner/work/oppia-android/oppia-android/scripts/feature_flags_check.sh $HOME + - name: Protobuf lint check run: | bash /home/runner/work/oppia-android/oppia-android/scripts/buf_lint_check.sh $HOME @@ -147,39 +155,53 @@ jobs: shell: bash - name: Regex Patterns Validation Check - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} run: | bazel run //scripts:regex_pattern_validation_check -- $(pwd) - name: XML Syntax Validation Check - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} run: | bazel run //scripts:xml_syntax_check -- $(pwd) - name: Testfile Presence Check - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} run: | bazel run //scripts:test_file_check -- $(pwd) - name: Accessibility label Check - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} run: | bazel run //scripts:accessibility_label_check -- $(pwd) scripts/assets/accessibility_label_exemptions.pb app/src/main/AndroidManifest.xml - name: KDoc Validation Check - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} run: | bazel run //scripts:kdoc_validity_check -- $(pwd) scripts/assets/kdoc_validity_exemptions.pb - name: Todo Check - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} env: GITHUB_TOKEN: ${{ github.token }} run: | bazel run //scripts:todo_open_check -- $(pwd) scripts/assets/todo_open_exemptions.pb - name: String Resource Validation Check - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} run: | bazel run //scripts:string_resource_validation_check -- $(pwd) @@ -198,16 +220,22 @@ jobs: version: 6.5.0 - name: Maven Repin Check - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} run: | REPIN=1 bazel run @unpinned_maven//:pin - name: Maven Dependencies Update Check - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} run: | bazel run //scripts:maven_dependencies_list_check -- $(pwd) third_party/maven_install.json scripts/assets/maven_dependencies.pb - name: License Texts Check - if: always() + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} run: | bazel run //scripts:license_texts_check -- $(pwd)/app/src/main/res/values/third_party_dependencies.xml diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index fb8436dcc96..f59a5b9bee7 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -12,6 +12,10 @@ on: # Push events on develop branch - develop +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: bazel_compute_affected_targets: name: Compute affected tests @@ -320,7 +324,9 @@ jobs: check_test_results: name: Check Bazel Test Results needs: [bazel_compute_affected_targets, bazel_run_test] - if: ${{ always() }} + # The expression if: ${{ !cancelled() }} runs a job or step regardless of its success or failure while responding to cancellations, + # serving as a cancellation-compliant alternative to if: ${{ always() }} in concurrent workflows. + if: ${{ !cancelled() }} runs-on: ubuntu-20.04 steps: # This step will be skipped if there are no tests to run, so the overall job should pass. diff --git a/.github/workflows/workflow_canceller.yml b/.github/workflows/workflow_canceller.yml deleted file mode 100644 index ece774a7e63..00000000000 --- a/.github/workflows/workflow_canceller.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Automatic Workflow Canceller - -# This workflow should be triggered in one of three situations: -# 1. Manual workflow dispatch via https://github.com/oppia/oppia-android/actions. -# 2. Upon creation of a PR & updates to that PR. -# -# Note that the action being used here automatically accounts for the current branch & the commit -# hash of the tip of the branch to ensure it doesn't cancel previous workflows that aren't related -# to the branch being evaluated. -on: - workflow_dispatch: - pull_request: - -jobs: - cancel: - name: Cancel Previous Runs - runs-on: ubuntu-20.04 - steps: - # See https://github.com/styfle/cancel-workflow-action for details on this workflow. - - uses: styfle/cancel-workflow-action@0.6.0 - with: - workflow_id: main.yml - access_token: ${{ github.token }} diff --git a/app/src/main/java/org/oppia/android/app/activity/ActivityIntentFactories.kt b/app/src/main/java/org/oppia/android/app/activity/ActivityIntentFactories.kt index ab6ab90bf77..078032aabcf 100644 --- a/app/src/main/java/org/oppia/android/app/activity/ActivityIntentFactories.kt +++ b/app/src/main/java/org/oppia/android/app/activity/ActivityIntentFactories.kt @@ -16,15 +16,23 @@ interface ActivityIntentFactories { * This must be injected within an activity context. */ interface TopicActivityIntentFactory { - /** Returns a new [Intent] to start the topic activity for the specified profile and topic. */ - fun createIntent(profileId: ProfileId, topicId: String): Intent + /** + * Returns a new [Intent] to start the topic activity for the specified profile, classroom + * and topic. + */ + fun createIntent(profileId: ProfileId, classroomId: String, topicId: String): Intent /** - * Returns a new [Intent] to start the topic activity for the specified profile, topic, and - * story (where the activity will automatically navigate to & expand the specified story in the - * topic). + * Returns a new [Intent] to start the topic activity for the specified profile, classroom, + * topic, and story (where the activity will automatically navigate to & expand the specified + * story in the topic). */ - fun createIntent(profileId: ProfileId, topicId: String, storyId: String): Intent + fun createIntent( + profileId: ProfileId, + classroomId: String, + topicId: String, + storyId: String + ): Intent } /** diff --git a/app/src/main/java/org/oppia/android/app/classroom/ClassroomListActivity.kt b/app/src/main/java/org/oppia/android/app/classroom/ClassroomListActivity.kt index 4ba06da7d58..c8f075f10bf 100644 --- a/app/src/main/java/org/oppia/android/app/classroom/ClassroomListActivity.kt +++ b/app/src/main/java/org/oppia/android/app/classroom/ClassroomListActivity.kt @@ -19,7 +19,8 @@ import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.RecentlyPlayedActivityParams import org.oppia.android.app.model.RecentlyPlayedActivityTitle import org.oppia.android.app.model.ScreenName.CLASSROOM_LIST_ACTIVITY -import org.oppia.android.app.topic.TopicActivity +import org.oppia.android.app.topic.TopicActivity.Companion.createTopicActivityIntent +import org.oppia.android.app.topic.TopicActivity.Companion.createTopicPlayStoryActivityIntent import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId @@ -98,15 +99,23 @@ class ClassroomListActivity : ) } - override fun routeToTopic(internalProfileId: Int, topicId: String) { - startActivity(TopicActivity.createTopicActivityIntent(this, internalProfileId, topicId)) + override fun routeToTopic(internalProfileId: Int, classroomId: String, topicId: String) { + startActivity( + createTopicActivityIntent(this, internalProfileId, classroomId, topicId) + ) } - override fun routeToTopicPlayStory(internalProfileId: Int, topicId: String, storyId: String) { + override fun routeToTopicPlayStory( + internalProfileId: Int, + classroomId: String, + topicId: String, + storyId: String + ) { startActivity( - TopicActivity.createTopicPlayStoryActivityIntent( + createTopicPlayStoryActivityIntent( this, internalProfileId, + classroomId, topicId, storyId ) diff --git a/app/src/main/java/org/oppia/android/app/classroom/ClassroomListFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/classroom/ClassroomListFragmentPresenter.kt index 7bc81fd5bdf..19da63a9d68 100644 --- a/app/src/main/java/org/oppia/android/app/classroom/ClassroomListFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/classroom/ClassroomListFragmentPresenter.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -27,8 +28,11 @@ import androidx.compose.ui.res.integerResource import androidx.compose.ui.unit.dp import androidx.databinding.ObservableList import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer import org.oppia.android.R +import org.oppia.android.app.classroom.classroomlist.AllClassroomsHeaderText import org.oppia.android.app.classroom.classroomlist.ClassroomList +import org.oppia.android.app.classroom.promotedlist.ComingSoonTopicList import org.oppia.android.app.classroom.promotedlist.PromotedStoryList import org.oppia.android.app.classroom.topiclist.AllTopicsHeaderText import org.oppia.android.app.classroom.topiclist.TopicCard @@ -36,10 +40,13 @@ import org.oppia.android.app.classroom.welcome.WelcomeText import org.oppia.android.app.home.HomeItemViewModel import org.oppia.android.app.home.RouteToTopicPlayStoryListener import org.oppia.android.app.home.WelcomeViewModel +import org.oppia.android.app.home.classroomlist.AllClassroomsViewModel import org.oppia.android.app.home.classroomlist.ClassroomSummaryViewModel +import org.oppia.android.app.home.promotedlist.ComingSoonTopicListViewModel import org.oppia.android.app.home.promotedlist.PromotedStoryListViewModel import org.oppia.android.app.home.topiclist.AllTopicsViewModel import org.oppia.android.app.home.topiclist.TopicSummaryViewModel +import org.oppia.android.app.model.AppStartupState import org.oppia.android.app.model.ClassroomSummary import org.oppia.android.app.model.LessonThumbnail import org.oppia.android.app.model.LessonThumbnailGraphic @@ -48,10 +55,14 @@ import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.datetime.DateTimeUtil import org.oppia.android.databinding.ClassroomListFragmentBinding import org.oppia.android.domain.classroom.ClassroomController +import org.oppia.android.domain.onboarding.AppStartupStateController import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.domain.topic.TopicListController import org.oppia.android.domain.translation.TranslationController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.locale.OppiaLocale import org.oppia.android.util.parser.html.StoryHtmlParserEntityType import org.oppia.android.util.parser.html.TopicHtmlParserEntityType @@ -75,6 +86,8 @@ class ClassroomListFragmentPresenter @Inject constructor( private val dateTimeUtil: DateTimeUtil, private val translationController: TranslationController, private val machineLocale: OppiaLocale.MachineLocale, + private val appStartupStateController: AppStartupStateController, + private val analyticsController: AnalyticsController, ) { private val routeToTopicPlayStoryListener = activity as RouteToTopicPlayStoryListener private lateinit var binding: ClassroomListFragmentBinding @@ -92,6 +105,8 @@ class ClassroomListFragmentPresenter @Inject constructor( internalProfileId = profileId.internalId + logHomeActivityEvent() + classroomListViewModel = ClassroomListViewModel( activity, fragment, @@ -144,6 +159,8 @@ class ClassroomListFragmentPresenter @Inject constructor( } ) + logAppOnboardedEvent() + return binding.root } @@ -151,6 +168,7 @@ class ClassroomListFragmentPresenter @Inject constructor( fun onTopicSummaryClicked(topicSummary: TopicSummary) { routeToTopicPlayStoryListener.routeToTopicPlayStory( internalProfileId, + topicSummary.classroomId, topicSummary.topicId, topicSummary.firstStoryId ) @@ -182,8 +200,15 @@ class ClassroomListFragmentPresenter @Inject constructor( ?.plus(classroomListViewModel.topicList) ?.groupBy { it::class } val topicListSpanCount = integerResource(id = R.integer.home_span_count) + val listState = rememberLazyListState() + val classroomListIndex = groupedItems + ?.flatMap { (type, items) -> items.map { type to it } } + ?.indexOfFirst { it.first == AllClassroomsViewModel::class } + ?: -1 + LazyColumn( - modifier = Modifier.testTag(CLASSROOM_LIST_SCREEN_TEST_TAG) + modifier = Modifier.testTag(CLASSROOM_LIST_SCREEN_TEST_TAG), + state = listState ) { groupedItems?.forEach { (type, items) -> when (type) { @@ -200,10 +225,24 @@ class ClassroomListFragmentPresenter @Inject constructor( ) } } - ClassroomSummaryViewModel::class -> stickyHeader { + ComingSoonTopicListViewModel::class -> items.forEach { item -> + item { + ComingSoonTopicList( + comingSoonTopicListViewModel = item as ComingSoonTopicListViewModel, + machineLocale = machineLocale, + ) + } + } + AllClassroomsViewModel::class -> items.forEach { _ -> + item { + AllClassroomsHeaderText() + } + } + ClassroomSummaryViewModel::class -> stickyHeader() { ClassroomList( classroomSummaryList = items.map { it as ClassroomSummaryViewModel }, - classroomListViewModel.selectedClassroomId.get() ?: "" + selectedClassroomId = classroomListViewModel.selectedClassroomId.get() ?: "", + isSticky = listState.firstVisibleItemIndex >= classroomListIndex ) } AllTopicsViewModel::class -> items.forEach { _ -> @@ -225,6 +264,45 @@ class ClassroomListFragmentPresenter @Inject constructor( } } } + + private fun logAppOnboardedEvent() { + val startupStateProvider = appStartupStateController.getAppStartupState() + val liveData = startupStateProvider.toLiveData() + liveData.observe( + activity, + object : Observer> { + override fun onChanged(startUpStateResult: AsyncResult?) { + when (startUpStateResult) { + null, is AsyncResult.Pending -> { + // Do nothing. + } + is AsyncResult.Success -> { + liveData.removeObserver(this) + + if (startUpStateResult.value.startupMode == + AppStartupState.StartupMode.USER_NOT_YET_ONBOARDED + ) { + analyticsController.logAppOnboardedEvent(profileId) + } + } + is AsyncResult.Failure -> { + oppiaLogger.e( + "ClassroomListFragment", + "Failed to retrieve app startup state" + ) + } + } + } + } + ) + } + + private fun logHomeActivityEvent() { + analyticsController.logImportantEvent( + oppiaLogger.createOpenHomeContext(), + profileId + ) + } } /** Adds a grid of items to a LazyListScope with specified arrangement and item content. */ diff --git a/app/src/main/java/org/oppia/android/app/classroom/ClassroomListViewModel.kt b/app/src/main/java/org/oppia/android/app/classroom/ClassroomListViewModel.kt index df1b5cd8f13..8211e52529b 100644 --- a/app/src/main/java/org/oppia/android/app/classroom/ClassroomListViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/classroom/ClassroomListViewModel.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.Transformations import org.oppia.android.R import org.oppia.android.app.home.HomeItemViewModel import org.oppia.android.app.home.WelcomeViewModel +import org.oppia.android.app.home.classroomlist.AllClassroomsViewModel import org.oppia.android.app.home.classroomlist.ClassroomSummaryClickListener import org.oppia.android.app.home.classroomlist.ClassroomSummaryViewModel import org.oppia.android.app.home.promotedlist.ComingSoonTopicListViewModel @@ -264,13 +265,14 @@ class ClassroomListViewModel( private fun computeClassroomItemViewModelList( classroomList: ClassroomList ): List { - return classroomList.classroomSummaryList.map { ephemeralClassroomSummary -> - ClassroomSummaryViewModel( - fragment as ClassroomSummaryClickListener, - ephemeralClassroomSummary, - translationController - ) - } + return listOf(AllClassroomsViewModel) + + classroomList.classroomSummaryList.map { ephemeralClassroomSummary -> + ClassroomSummaryViewModel( + fragment as ClassroomSummaryClickListener, + ephemeralClassroomSummary, + translationController + ) + } } /** diff --git a/app/src/main/java/org/oppia/android/app/classroom/classroomlist/AllClassroomsHeaderText.kt b/app/src/main/java/org/oppia/android/app/classroom/classroomlist/AllClassroomsHeaderText.kt new file mode 100644 index 00000000000..2e611b069d2 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/classroom/classroomlist/AllClassroomsHeaderText.kt @@ -0,0 +1,36 @@ +package org.oppia.android.app.classroom.classroomlist + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import org.oppia.android.R + +/** Test tag for the all classrooms section header. */ +const val ALL_CLASSROOMS_HEADER_TEST_TAG = "TEST_TAG.all_classrooms_header" + +/** Displays the header text for the classroom list section. */ +@Composable +fun AllClassroomsHeaderText() { + Text( + text = stringResource(id = R.string.classrooms_list_activity_section_header), + color = colorResource(id = R.color.component_color_classroom_shared_header_text_color), + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = dimensionResource(id = R.dimen.classrooms_list_header_text_size).value.sp, + modifier = Modifier + .testTag(ALL_CLASSROOMS_HEADER_TEST_TAG) + .padding( + start = dimensionResource(id = R.dimen.classrooms_text_margin_start), + top = dimensionResource(id = R.dimen.classrooms_text_margin_top), + end = dimensionResource(id = R.dimen.classrooms_text_margin_end), + ), + ) +} diff --git a/app/src/main/java/org/oppia/android/app/classroom/classroomlist/ClassroomList.kt b/app/src/main/java/org/oppia/android/app/classroom/classroomlist/ClassroomList.kt index 5c35b3383b1..5a5db784fec 100644 --- a/app/src/main/java/org/oppia/android/app/classroom/classroomlist/ClassroomList.kt +++ b/app/src/main/java/org/oppia/android/app/classroom/classroomlist/ClassroomList.kt @@ -1,6 +1,6 @@ package org.oppia.android.app.classroom.classroomlist -import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -8,8 +8,6 @@ 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.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -24,60 +22,44 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.integerResource import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.oppia.android.R import org.oppia.android.app.classroom.getDrawableResource import org.oppia.android.app.home.classroomlist.ClassroomSummaryViewModel -/** Test tag for the header of the classroom section. */ -const val CLASSROOM_HEADER_TEST_TAG = "TEST_TAG.classroom_header" - /** Test tag for the classroom list. */ const val CLASSROOM_LIST_TEST_TAG = "TEST_TAG.classroom_list" +/** Test tag for the classroom card icon. */ +const val CLASSROOM_CARD_ICON_TEST_TAG = "TEST_TAG.classroom_card_icon" + /** Displays a list of classroom summaries with a header. */ @Composable fun ClassroomList( classroomSummaryList: List, - selectedClassroomId: String + selectedClassroomId: String, + isSticky: Boolean, ) { - Column( + LazyRow( modifier = Modifier + .testTag(CLASSROOM_LIST_TEST_TAG) .background( color = colorResource(id = R.color.component_color_shared_screen_primary_background_color) - ) - .fillMaxWidth(), - ) { - Text( - text = stringResource(id = R.string.classrooms_list_activity_section_header), - color = colorResource(id = R.color.component_color_shared_primary_text_color), - fontFamily = FontFamily.SansSerif, - fontWeight = FontWeight.Medium, - fontSize = dimensionResource(id = R.dimen.classrooms_list_header_text_size).value.sp, - modifier = Modifier - .testTag(CLASSROOM_HEADER_TEST_TAG) - .padding( - start = dimensionResource(id = R.dimen.classrooms_text_margin_start), - top = dimensionResource(id = R.dimen.classrooms_text_margin_top), - end = dimensionResource(id = R.dimen.classrooms_text_margin_end), - bottom = dimensionResource(id = R.dimen.classrooms_text_margin_bottom), - ), - ) - LazyRow( - modifier = Modifier.testTag(CLASSROOM_LIST_TEST_TAG), - contentPadding = PaddingValues( - start = dimensionResource(id = R.dimen.classrooms_text_margin_start), - end = dimensionResource(id = R.dimen.classrooms_text_margin_end), ), - ) { - items(classroomSummaryList) { - ClassroomCard(classroomSummaryViewModel = it, selectedClassroomId) - } + contentPadding = PaddingValues( + start = dimensionResource(id = R.dimen.classrooms_text_margin_start), + top = dimensionResource(id = R.dimen.classrooms_text_margin_bottom), + end = dimensionResource(id = R.dimen.classrooms_text_margin_end), + ), + ) { + items(classroomSummaryList) { + ClassroomCard(classroomSummaryViewModel = it, selectedClassroomId, isSticky) } } } @@ -86,37 +68,41 @@ fun ClassroomList( @Composable fun ClassroomCard( classroomSummaryViewModel: ClassroomSummaryViewModel, - selectedClassroomId: String + selectedClassroomId: String, + isSticky: Boolean, ) { - val screenWidth = LocalConfiguration.current.screenWidthDp - val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT - val isTablet = if (isPortrait) screenWidth > 600 else screenWidth > 840 val isCardSelected = classroomSummaryViewModel.classroomSummary.classroomId == selectedClassroomId Card( modifier = Modifier - .height(dimensionResource(id = R.dimen.classrooms_card_height)) - .width(dimensionResource(id = R.dimen.classrooms_card_width)) + .width(getClassroomCardWidth()) .padding( - start = dimensionResource(R.dimen.promoted_story_card_layout_margin_start), - end = dimensionResource(R.dimen.promoted_story_card_layout_margin_end), + start = dimensionResource(R.dimen.classrooms_card_margin_start), + end = dimensionResource(R.dimen.classrooms_card_margin_end), ) .clickable { classroomSummaryViewModel.handleClassroomClick() }, backgroundColor = if (isCardSelected) { - colorResource(id = R.color.component_color_classroom_card_color) + colorResource(id = R.color.component_color_classroom_card_selected_color) } else { - colorResource(id = R.color.component_color_shared_screen_primary_background_color) + colorResource(id = R.color.component_color_classroom_card_color) }, border = BorderStroke(2.dp, color = colorResource(id = R.color.color_def_oppia_green)), elevation = dimensionResource(id = R.dimen.classrooms_card_elevation), ) { Column( - modifier = Modifier.padding(all = dimensionResource(id = R.dimen.classrooms_card_padding)), + modifier = Modifier.padding( + horizontal = dimensionResource(id = R.dimen.classrooms_card_padding_horizontal), + vertical = if (isSticky) { + dimensionResource(id = R.dimen.classrooms_card_collapsed_padding_vertical) + } else { + dimensionResource(id = R.dimen.classrooms_card_padding_vertical) + }, + ), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - if (isPortrait || isTablet) { // Hides the classroom icon for landscape phone layouts. + AnimatedVisibility(visible = !isSticky) { Image( painter = painterResource( id = classroomSummaryViewModel @@ -126,6 +112,7 @@ fun ClassroomCard( ), contentDescription = classroomSummaryViewModel.title, modifier = Modifier + .testTag("${CLASSROOM_CARD_ICON_TEST_TAG}_${classroomSummaryViewModel.title}") .padding(bottom = dimensionResource(id = R.dimen.classrooms_card_icon_padding_bottom)) .size(size = dimensionResource(id = R.dimen.classrooms_card_icon_size)), ) @@ -140,3 +127,17 @@ fun ClassroomCard( } } } + +@Composable +private fun getClassroomCardWidth(): Dp { + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + val horizontalPadding = dimensionResource(id = R.dimen.classrooms_text_margin_start) + val topicCardHorizontalMargin = 8.dp + val topicListSpanCount = integerResource(id = R.integer.home_span_count) + + val totalTopicCardWidth = screenWidth - + (horizontalPadding.times(2) + (topicCardHorizontalMargin * (topicListSpanCount - 1) * 2)) + + return totalTopicCardWidth.div(topicListSpanCount) +} diff --git a/app/src/main/java/org/oppia/android/app/classroom/promotedlist/ComingSoonTopicList.kt b/app/src/main/java/org/oppia/android/app/classroom/promotedlist/ComingSoonTopicList.kt new file mode 100644 index 00000000000..0d9de4ef7b0 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/classroom/promotedlist/ComingSoonTopicList.kt @@ -0,0 +1,181 @@ +package org.oppia.android.app.classroom.promotedlist + +import androidx.compose.foundation.Image +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.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +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.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.oppia.android.R +import org.oppia.android.app.classroom.getDrawableResource +import org.oppia.android.app.home.promotedlist.ComingSoonTopicListViewModel +import org.oppia.android.app.home.promotedlist.ComingSoonTopicsViewModel +import org.oppia.android.util.locale.OppiaLocale + +/** Test tag for the header of the promoted story list. */ +const val COMING_SOON_TOPIC_LIST_HEADER_TEST_TAG = "TEST_TAG.coming_soon_topic_list_header" + +/** Test tag for the promoted story list. */ +const val COMING_SOON_TOPIC_LIST_TEST_TAG = "TEST_TAG.coming_soon_topic_list" + +/** Displays a list of topics to be published soon. */ +@Composable +fun ComingSoonTopicList( + comingSoonTopicListViewModel: ComingSoonTopicListViewModel, + machineLocale: OppiaLocale.MachineLocale, +) { + Text( + text = stringResource(id = R.string.coming_soon), + color = colorResource(id = R.color.component_color_shared_primary_text_color), + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = dimensionResource(id = R.dimen.coming_soon_topic_list_header_text_size).value.sp, + modifier = Modifier + .padding( + start = dimensionResource(id = R.dimen.coming_soon_topic_list_layout_margin_start), + top = dimensionResource(id = R.dimen.coming_soon_topic_list_layout_margin_top), + end = dimensionResource(id = R.dimen.coming_soon_topic_list_layout_margin_end), + ) + .testTag(COMING_SOON_TOPIC_LIST_HEADER_TEST_TAG), + ) + LazyRow( + modifier = Modifier + .padding( + top = dimensionResource(id = R.dimen.coming_soon_topic_list_padding) + ) + .testTag(COMING_SOON_TOPIC_LIST_TEST_TAG), + contentPadding = PaddingValues( + start = dimensionResource(id = R.dimen.coming_soon_topic_list_layout_margin_start), + end = dimensionResource(id = R.dimen.home_padding_end), + ), + ) { + items(comingSoonTopicListViewModel.comingSoonTopicList) { + ComingSoonTopicCard( + comingSoonTopicsViewModel = it, + machineLocale = machineLocale, + ) + } + } +} + +/** Displays a card with the coming soon topic summary information. */ +@Composable +fun ComingSoonTopicCard( + comingSoonTopicsViewModel: ComingSoonTopicsViewModel, + machineLocale: OppiaLocale.MachineLocale, +) { + Card( + modifier = Modifier + .width(dimensionResource(id = R.dimen.coming_soon_topic_card_width)) + .padding( + start = dimensionResource(id = R.dimen.coming_soon_topic_card_layout_margin_start), + end = dimensionResource(id = R.dimen.coming_soon_topic_card_layout_margin_end), + bottom = dimensionResource(id = R.dimen.coming_soon_topic_card_layout_margin_bottom), + ), + elevation = dimensionResource(id = R.dimen.topic_card_elevation), + ) { + Box( + contentAlignment = Alignment.TopEnd + ) { + Column( + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource( + id = comingSoonTopicsViewModel.topicSummary.lessonThumbnail.getDrawableResource() + ), + contentDescription = "Picture of a " + + "${comingSoonTopicsViewModel.topicSummary.lessonThumbnail.thumbnailGraphic.name}.", + modifier = Modifier + .aspectRatio(4f / 3f) + .background( + Color( + ( + 0xff000000L or + comingSoonTopicsViewModel + .topicSummary.lessonThumbnail.backgroundColorRgb.toLong() + ).toInt() + ) + ) + ) + ComingSoonTopicCardTextSection(comingSoonTopicsViewModel) + } + Text( + text = machineLocale + .run { stringResource(id = R.string.coming_soon).toMachineUpperCase() }, + modifier = Modifier + .background( + color = colorResource( + id = R.color.component_color_coming_soon_rect_background_start_color + ), + shape = RoundedCornerShape(topEnd = 4.dp, bottomStart = 12.dp), + ) + .padding( + horizontal = dimensionResource(id = R.dimen.coming_soon_text_padding_horizontal), + vertical = dimensionResource(id = R.dimen.coming_soon_text_padding_vertical), + ), + fontSize = 12.sp, + color = colorResource(id = R.color.component_color_shared_secondary_4_text_color), + fontFamily = FontFamily.SansSerif, + textAlign = TextAlign.End, + ) + } + } +} + +/** Displays the topic title. */ +@Composable +fun ComingSoonTopicCardTextSection(comingSoonTopicsViewModel: ComingSoonTopicsViewModel) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = colorResource( + id = R.color.component_color_shared_topic_card_item_background_color + ) + ), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = comingSoonTopicsViewModel.topicTitle, + modifier = Modifier + .fillMaxWidth() + .padding( + start = dimensionResource(id = R.dimen.coming_soon_topic_card_text_padding), + top = dimensionResource(id = R.dimen.coming_soon_topic_card_text_padding), + end = dimensionResource(id = R.dimen.coming_soon_topic_card_text_padding), + bottom = dimensionResource(id = R.dimen.coming_soon_topic_card_text_padding_bottom), + ), + color = colorResource(id = R.color.component_color_shared_secondary_4_text_color), + fontFamily = FontFamily.SansSerif, + fontSize = dimensionResource(id = R.dimen.topic_list_item_text_size).value.sp, + textAlign = TextAlign.Start, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/app/src/main/java/org/oppia/android/app/classroom/promotedlist/PromotedList.kt b/app/src/main/java/org/oppia/android/app/classroom/promotedlist/PromotedStoryList.kt similarity index 81% rename from app/src/main/java/org/oppia/android/app/classroom/promotedlist/PromotedList.kt rename to app/src/main/java/org/oppia/android/app/classroom/promotedlist/PromotedStoryList.kt index 3ac2bdebf1d..ba9fcc32e13 100644 --- a/app/src/main/java/org/oppia/android/app/classroom/promotedlist/PromotedList.kt +++ b/app/src/main/java/org/oppia/android/app/classroom/promotedlist/PromotedStoryList.kt @@ -7,6 +7,7 @@ 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.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -52,40 +53,46 @@ fun PromotedStoryList( promotedStoryListViewModel: PromotedStoryListViewModel, machineLocale: OppiaLocale.MachineLocale, ) { - Row( - modifier = Modifier - .testTag(PROMOTED_STORY_LIST_HEADER_TEST_TAG) - .fillMaxWidth() - .padding( - start = dimensionResource(id = R.dimen.promoted_story_list_layout_margin_start), - top = dimensionResource(id = R.dimen.promoted_story_list_layout_margin_top), - end = dimensionResource(id = R.dimen.promoted_story_list_layout_margin_end), - ), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Box( + contentAlignment = Alignment.Center, ) { - Text( - text = promotedStoryListViewModel.getHeader(), - color = colorResource(id = R.color.component_color_shared_primary_text_color), - fontFamily = FontFamily.SansSerif, - fontWeight = FontWeight.Medium, - fontSize = dimensionResource(id = R.dimen.promoted_story_list_header_text_size).value.sp, + Row( modifier = Modifier - .weight(weight = 1f, fill = false), - ) - if (promotedStoryListViewModel.getViewAllButtonVisibility() == View.VISIBLE) { + .testTag(PROMOTED_STORY_LIST_HEADER_TEST_TAG) + .fillMaxWidth() + .padding( + start = dimensionResource(id = R.dimen.promoted_story_list_layout_margin_start), + top = dimensionResource(id = R.dimen.promoted_story_list_layout_margin_top), + end = dimensionResource(id = R.dimen.promoted_story_list_layout_margin_end), + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { Text( - text = machineLocale.run { stringResource(id = R.string.view_all).toMachineUpperCase() }, - color = colorResource(id = R.color.component_color_home_activity_view_all_text_color), + text = promotedStoryListViewModel.getHeader(), + color = colorResource(id = R.color.component_color_classroom_shared_header_text_color), fontFamily = FontFamily.SansSerif, - fontWeight = FontWeight.Medium, - fontSize = dimensionResource(id = R.dimen.promoted_story_list_view_all_text_size).value.sp, + fontWeight = FontWeight.Normal, + fontSize = dimensionResource(id = R.dimen.promoted_story_list_header_text_size).value.sp, modifier = Modifier - .padding( - start = dimensionResource(id = R.dimen.promoted_story_list_view_all_padding_start) - ) - .clickable { promotedStoryListViewModel.clickOnViewAll() }, + .weight(weight = 1f, fill = false), ) + if (promotedStoryListViewModel.getViewAllButtonVisibility() == View.VISIBLE) { + Text( + text = machineLocale.run { stringResource(id = R.string.view_all).toMachineUpperCase() }, + color = colorResource(id = R.color.component_color_home_activity_view_all_text_color), + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + fontSize = dimensionResource( + id = R.dimen.promoted_story_list_view_all_text_size + ).value.sp, + modifier = Modifier + .padding( + start = dimensionResource(id = R.dimen.promoted_story_list_view_all_padding_start) + ) + .clickable { promotedStoryListViewModel.clickOnViewAll() }, + ) + } } } LazyRow( @@ -127,7 +134,7 @@ fun PromotedStoryCard( ) .clickable { promotedStoryViewModel.clickOnStoryTile() }, backgroundColor = colorResource( - id = R.color.component_color_shared_screen_primary_background_color + id = R.color.component_color_classroom_promoted_list_card_background_color ), elevation = dimensionResource(id = R.dimen.promoted_story_card_elevation), ) { diff --git a/app/src/main/java/org/oppia/android/app/classroom/topiclist/AllTopicsHeaderText.kt b/app/src/main/java/org/oppia/android/app/classroom/topiclist/AllTopicsHeaderText.kt index 31dec186c60..a7f6336759c 100644 --- a/app/src/main/java/org/oppia/android/app/classroom/topiclist/AllTopicsHeaderText.kt +++ b/app/src/main/java/org/oppia/android/app/classroom/topiclist/AllTopicsHeaderText.kt @@ -30,7 +30,9 @@ fun AllTopicsHeaderText() { modifier = Modifier .testTag(ALL_TOPICS_HEADER_TEST_TAG) .fillMaxWidth() - .background(colorResource(id = R.color.color_palette_classroom_topic_list_background_color)) + .background( + colorResource(id = R.color.component_color_classroom_topic_list_background_color) + ) .padding( start = dimensionResource(id = R.dimen.all_topics_text_margin_start), top = dimensionResource(id = R.dimen.all_topics_text_margin_top), diff --git a/app/src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryItemViewModel.kt b/app/src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryItemViewModel.kt index 355389d59b5..6e8507554c0 100644 --- a/app/src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryItemViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryItemViewModel.kt @@ -31,13 +31,24 @@ class CompletedStoryItemViewModel( /** Called when user clicks on CompletedStoryItem. */ fun onCompletedStoryItemClicked() { - routeToTopicPlayStory(internalProfileId, completedStory.topicId, completedStory.storyId) + routeToTopicPlayStory( + internalProfileId, + completedStory.classroomId, + completedStory.topicId, + completedStory.storyId + ) } - override fun routeToTopicPlayStory(internalProfileId: Int, topicId: String, storyId: String) { + override fun routeToTopicPlayStory( + internalProfileId: Int, + classroomId: String, + topicId: String, + storyId: String + ) { val intent = intentFactoryShim.createTopicPlayStoryActivityIntent( activity.applicationContext, internalProfileId, + classroomId, topicId, storyId ) diff --git a/app/src/main/java/org/oppia/android/app/home/HomeActivity.kt b/app/src/main/java/org/oppia/android/app/home/HomeActivity.kt index 8d6f828abd0..34885717a33 100644 --- a/app/src/main/java/org/oppia/android/app/home/HomeActivity.kt +++ b/app/src/main/java/org/oppia/android/app/home/HomeActivity.kt @@ -67,8 +67,10 @@ class HomeActivity : homeActivityPresenter.handleOnRestart() } - override fun routeToTopic(internalProfileId: Int, topicId: String) { - startActivity(TopicActivity.createTopicActivityIntent(this, internalProfileId, topicId)) + override fun routeToTopic(internalProfileId: Int, classroomId: String, topicId: String) { + startActivity( + TopicActivity.createTopicActivityIntent(this, internalProfileId, classroomId, topicId) + ) } override fun onBackPressed() { @@ -87,11 +89,17 @@ class HomeActivity : dialogFragment.showNow(supportFragmentManager, TAG_SWITCH_PROFILE_DIALOG) } - override fun routeToTopicPlayStory(internalProfileId: Int, topicId: String, storyId: String) { + override fun routeToTopicPlayStory( + internalProfileId: Int, + classroomId: String, + topicId: String, + storyId: String + ) { startActivity( TopicActivity.createTopicPlayStoryActivityIntent( this, internalProfileId, + classroomId, topicId, storyId ) diff --git a/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt index 64d53c68009..b3ef5d04e3f 100644 --- a/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt @@ -197,6 +197,7 @@ class HomeFragmentPresenter @Inject constructor( fun onTopicSummaryClicked(topicSummary: TopicSummary) { routeToTopicPlayStoryListener.routeToTopicPlayStory( internalProfileId, + topicSummary.classroomId, topicSummary.topicId, topicSummary.firstStoryId ) diff --git a/app/src/main/java/org/oppia/android/app/home/RouteToExplorationListener.kt b/app/src/main/java/org/oppia/android/app/home/RouteToExplorationListener.kt index 3c8081a3152..9523938373d 100755 --- a/app/src/main/java/org/oppia/android/app/home/RouteToExplorationListener.kt +++ b/app/src/main/java/org/oppia/android/app/home/RouteToExplorationListener.kt @@ -7,6 +7,7 @@ import org.oppia.android.app.model.ProfileId interface RouteToExplorationListener { fun routeToExploration( profileId: ProfileId, + classroomId: String, topicId: String, storyId: String, explorationId: String, diff --git a/app/src/main/java/org/oppia/android/app/home/RouteToTopicListener.kt b/app/src/main/java/org/oppia/android/app/home/RouteToTopicListener.kt index 36c7a1a1d94..c00a7dc8389 100755 --- a/app/src/main/java/org/oppia/android/app/home/RouteToTopicListener.kt +++ b/app/src/main/java/org/oppia/android/app/home/RouteToTopicListener.kt @@ -2,5 +2,5 @@ package org.oppia.android.app.home /** Listener for when an activity should route to a topic. */ interface RouteToTopicListener { - fun routeToTopic(internalProfileId: Int, topicId: String) + fun routeToTopic(internalProfileId: Int, classroomId: String, topicId: String) } diff --git a/app/src/main/java/org/oppia/android/app/home/RouteToTopicPlayStoryListener.kt b/app/src/main/java/org/oppia/android/app/home/RouteToTopicPlayStoryListener.kt index aa7121f1c07..4d45619ede8 100755 --- a/app/src/main/java/org/oppia/android/app/home/RouteToTopicPlayStoryListener.kt +++ b/app/src/main/java/org/oppia/android/app/home/RouteToTopicPlayStoryListener.kt @@ -2,5 +2,10 @@ package org.oppia.android.app.home /** Listener for when an activity should route to a story-item in TopicPlay tab. */ interface RouteToTopicPlayStoryListener { - fun routeToTopicPlayStory(internalProfileId: Int, topicId: String, storyId: String) + fun routeToTopicPlayStory( + internalProfileId: Int, + classroomId: String, + topicId: String, + storyId: String + ) } diff --git a/app/src/main/java/org/oppia/android/app/home/classroomlist/AllClassroomsViewModel.kt b/app/src/main/java/org/oppia/android/app/home/classroomlist/AllClassroomsViewModel.kt new file mode 100644 index 00000000000..b0a1b366aa8 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/home/classroomlist/AllClassroomsViewModel.kt @@ -0,0 +1,8 @@ +package org.oppia.android.app.home.classroomlist + +import androidx.lifecycle.ViewModel +import org.oppia.android.app.classroom.ClassroomListFragment +import org.oppia.android.app.home.HomeItemViewModel + +/** [ViewModel] for displaying the classroom header in [ClassroomListFragment]. */ +object AllClassroomsViewModel : HomeItemViewModel() diff --git a/app/src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryViewModel.kt b/app/src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryViewModel.kt index 6b300f6355d..dae19f0866e 100755 --- a/app/src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryViewModel.kt @@ -63,6 +63,7 @@ class PromotedStoryViewModel( fun clickOnStoryTile() { routeToTopicPlayStoryListener.routeToTopicPlayStory( internalProfileId, + promotedStory.classroomId, promotedStory.topicId, promotedStory.storyId ) diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedActivity.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedActivity.kt index bf852642259..3450d510fbe 100644 --- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedActivity.kt +++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedActivity.kt @@ -62,6 +62,7 @@ class RecentlyPlayedActivity : override fun routeToExploration( profileId: ProfileId, + classroomId: String, topicId: String, storyId: String, explorationId: String, @@ -72,6 +73,7 @@ class RecentlyPlayedActivity : ExplorationActivity.createExplorationActivityIntent( this, profileId, + classroomId, topicId, storyId, explorationId, @@ -83,6 +85,7 @@ class RecentlyPlayedActivity : override fun routeToResumeLesson( profileId: ProfileId, + classroomId: String, topicId: String, storyId: String, explorationId: String, @@ -93,6 +96,7 @@ class RecentlyPlayedActivity : ResumeLessonActivity.createResumeLessonActivityIntent( this, profileId, + classroomId, topicId, storyId, explorationId, diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt index fcd8a8b3c7c..98b7617e0eb 100755 --- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt @@ -108,6 +108,7 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( explorationCheckpointLiveData.removeObserver(this) routeToResumeLessonListener.routeToResumeLesson( profileId, + promotedStory.classroomId, promotedStory.topicId, promotedStory.storyId, promotedStory.explorationId, @@ -117,6 +118,7 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( } else if (it is AsyncResult.Failure) { explorationCheckpointLiveData.removeObserver(this) playExploration( + promotedStory.classroomId, promotedStory.topicId, promotedStory.storyId, promotedStory.explorationId, @@ -128,6 +130,7 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( ) } else { playExploration( + promotedStory.classroomId, promotedStory.topicId, promotedStory.storyId, promotedStory.explorationId, @@ -162,6 +165,7 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( } private fun playExploration( + classroomId: String, topicId: String, storyId: String, explorationId: String, @@ -174,13 +178,13 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( // cases, lessons played from this fragment are known to be in progress, and that progress // can't be resumed here (hence the restart). explorationDataController.restartExploration( - profileId.internalId, topicId, storyId, explorationId + profileId.internalId, classroomId, topicId, storyId, explorationId ) } else { // The only lessons that can't have their progress saved are those that were already // completed. explorationDataController.replayExploration( - profileId.internalId, topicId, storyId, explorationId + profileId.internalId, classroomId, topicId, storyId, explorationId ) } startPlayingProvider.toLiveData().observe(fragment) { result -> @@ -192,6 +196,7 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( oppiaLogger.d("RecentlyPlayedFragment", "Successfully loaded exploration") routeToExplorationListener.routeToExploration( profileId, + classroomId, topicId, storyId, explorationId, diff --git a/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicItemViewModel.kt b/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicItemViewModel.kt index 2f8e6f9e84d..1b074ed6f1b 100644 --- a/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicItemViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicItemViewModel.kt @@ -27,7 +27,7 @@ class OngoingTopicItemViewModel( } fun onTopicItemClicked() { - routeToTopic(internalProfileId, topic.topicId) + routeToTopic(internalProfileId, topic.classroomId, topic.topicId) } fun computeStoryCountText(): String { @@ -36,10 +36,11 @@ class OngoingTopicItemViewModel( ) } - override fun routeToTopic(internalProfileId: Int, topicId: String) { + override fun routeToTopic(internalProfileId: Int, classroomId: String, topicId: String) { val intent = intentFactoryShim.createTopicActivityIntent( activity.applicationContext, internalProfileId, + classroomId, topicId ) activity.startActivity(intent) diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt index 4e73ac992e3..b95785bf2d0 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt @@ -60,6 +60,7 @@ class ExplorationActivity : explorationActivityPresenter.handleOnCreate( this, params.profileId, + params.classroomId, params.topicId, params.storyId, params.explorationId, @@ -79,6 +80,7 @@ class ExplorationActivity : fun createExplorationActivityIntent( context: Context, profileId: ProfileId, + classroomId: String, topicId: String, storyId: String, explorationId: String, @@ -87,6 +89,7 @@ class ExplorationActivity : ): Intent { val params = ExplorationActivityParams.newBuilder().apply { this.profileId = profileId + this.classroomId = classroomId this.topicId = topicId this.storyId = storyId this.explorationId = explorationId diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt index 76812ff7522..9d1c50ec2ea 100644 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt @@ -70,6 +70,7 @@ class ExplorationActivityPresenter @Inject constructor( private lateinit var explorationToolbar: Toolbar private lateinit var explorationToolbarTitle: TextView private lateinit var profileId: ProfileId + private lateinit var classroomId: String private lateinit var topicId: String private lateinit var storyId: String private lateinit var explorationId: String @@ -85,6 +86,7 @@ class ExplorationActivityPresenter @Inject constructor( fun handleOnCreate( context: Context, profileId: ProfileId, + classroomId: String, topicId: String, storyId: String, explorationId: String, @@ -125,6 +127,7 @@ class ExplorationActivityPresenter @Inject constructor( } this.profileId = profileId + this.classroomId = classroomId this.topicId = topicId this.storyId = storyId this.explorationId = explorationId @@ -187,6 +190,7 @@ class ExplorationActivityPresenter @Inject constructor( R.id.exploration_fragment_placeholder, ExplorationFragment.newInstance( profileId, + classroomId, topicId, storyId, explorationId, @@ -386,7 +390,12 @@ class ExplorationActivityPresenter @Inject constructor( ExplorationActivityParams.ParentScreen.UNRECOGNIZED -> { // Default to the topic activity. activity.startActivity( - TopicActivity.createTopicActivityIntent(context, profileId.internalId, topicId) + TopicActivity.createTopicActivityIntent( + context, + profileId.internalId, + classroomId, + topicId + ) ) activity.finish() } diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt index 8411ab49c46..fdffb73b32d 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt @@ -22,6 +22,7 @@ class ExplorationFragment : InjectableFragment() { /** Returns a new [ExplorationFragment] with the corresponding fragment parameters. */ fun newInstance( profileId: ProfileId, + classroomId: String, topicId: String, storyId: String, explorationId: String, @@ -29,6 +30,7 @@ class ExplorationFragment : InjectableFragment() { ): ExplorationFragment { val args = ExplorationFragmentArguments.newBuilder().apply { this.profileId = profileId + this.classroomId = classroomId this.topicId = topicId this.storyId = storyId this.explorationId = explorationId diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt index a64fa466a65..151f2456f53 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt @@ -58,7 +58,7 @@ class ExplorationFragmentPresenter @Inject constructor( StateFragment.newInstance( args.profileId.internalId, args.topicId, args.storyId, args.explorationId ) - logPracticeFragmentEvent(args.topicId, args.storyId, args.explorationId) + logPracticeFragmentEvent(args.classroomId, args.topicId, args.storyId, args.explorationId) if (getStateFragment() == null) { fragment.childFragmentManager.beginTransaction().add( R.id.state_fragment_placeholder, @@ -153,9 +153,16 @@ class ExplorationFragmentPresenter @Inject constructor( ) as StateFragment? } - private fun logPracticeFragmentEvent(topicId: String, storyId: String, explorationId: String) { + private fun logPracticeFragmentEvent( + classroomId: String, + topicId: String, + storyId: String, + explorationId: String + ) { analyticsController.logImportantEvent( - oppiaLogger.createOpenExplorationActivityContext(topicId, storyId, explorationId), + oppiaLogger.createOpenExplorationActivityContext( + classroomId, topicId, storyId, explorationId + ), ProfileId.newBuilder().apply { internalId = internalProfileId }.build() ) } diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt index afd00a47f9b..50f05d60082 100755 --- a/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt @@ -10,6 +10,7 @@ import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.model.HelpIndex import org.oppia.android.app.model.StateFragmentArguments import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -42,6 +43,9 @@ class StateFragment : /** Arguments key for StateFragment. */ const val STATE_FRAGMENT_ARGUMENTS_KEY = "StateFragment.arguments" + /** Arguments key for StateFragment saved state. */ + const val STATE_FRAGMENT_STATE_KEY = "StateFragment.state" + /** * Creates a new instance of a StateFragment. * @param internalProfileId used by StateFragment to mark progress. @@ -86,6 +90,12 @@ class StateFragment : ): View? { val args = arguments?.getProto(STATE_FRAGMENT_ARGUMENTS_KEY, StateFragmentArguments.getDefaultInstance()) + + val userAnswerState = savedInstanceState?.getProto( + STATE_FRAGMENT_STATE_KEY, + UserAnswerState.getDefaultInstance() + ) ?: UserAnswerState.getDefaultInstance() + val internalProfileId = args?.internalProfileId ?: -1 val topicId = args?.topicId!! val storyId = args.storyId!! @@ -97,7 +107,8 @@ class StateFragment : internalProfileId, topicId, storyId, - explorationId + explorationId, + userAnswerState ) } @@ -154,4 +165,12 @@ class StateFragment : fun dismissConceptCard() = stateFragmentPresenter.dismissConceptCard() fun getExplorationCheckpointState() = stateFragmentPresenter.getExplorationCheckpointState() + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putProto( + STATE_FRAGMENT_STATE_KEY, + stateFragmentPresenter.getUserAnswerState() + ) + } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt index a6726774c96..672595d81ef 100755 --- a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt @@ -27,6 +27,7 @@ import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.State import org.oppia.android.app.model.SurveyQuestionName import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.player.audio.AudioButtonListener import org.oppia.android.app.player.audio.AudioFragment import org.oppia.android.app.player.audio.AudioUiManager @@ -111,7 +112,8 @@ class StateFragmentPresenter @Inject constructor( internalProfileId: Int, topicId: String, storyId: String, - explorationId: String + explorationId: String, + userAnswerState: UserAnswerState ): View? { profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build() this.topicId = topicId @@ -125,7 +127,7 @@ class StateFragmentPresenter @Inject constructor( /* attachToRoot= */ false ) recyclerViewAssembler = createRecyclerViewAssembler( - assemblerBuilderFactory.create(resourceBucketName, entityType, profileId), + assemblerBuilderFactory.create(resourceBucketName, entityType, profileId, userAnswerState), binding.congratulationsTextView, binding.congratulationsTextConfettiView, binding.fullScreenConfettiView @@ -373,6 +375,7 @@ class StateFragmentPresenter @Inject constructor( private fun subscribeToAnswerOutcome( answerOutcomeResultLiveData: LiveData> ) { + recyclerViewAssembler.resetUserAnswerState() val answerOutcomeLiveData = getAnswerOutcome(answerOutcomeResultLiveData) answerOutcomeLiveData.observe( fragment, @@ -393,6 +396,11 @@ class StateFragmentPresenter @Inject constructor( ) } + /** Returns the [UserAnswerState] representing the user's current pending answer. */ + fun getUserAnswerState(): UserAnswerState { + return stateViewModel.getUserAnswerState(recyclerViewAssembler::getPendingAnswerHandler) + } + /** Helper for subscribeToAnswerOutcome. */ private fun getAnswerOutcome( answerOutcome: LiveData> @@ -438,9 +446,8 @@ class StateFragmentPresenter @Inject constructor( val inputManager: InputMethodManager = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager inputManager.hideSoftInputFromWindow( - fragment.view!!.windowToken, - @Suppress("DEPRECATION") // TODO(#5406): Use the correct constant value here. - InputMethodManager.SHOW_FORCED + fragment.requireView().windowToken, + 0 // Flag value to force hide the keyboard when possible. ) } diff --git a/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt b/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt index e0de8c91d58..8f1e627d77a 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt @@ -28,6 +28,7 @@ import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.StringList import org.oppia.android.app.model.SubtitledHtml import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.audio.AudioUiManager import org.oppia.android.app.player.state.StatePlayerRecyclerViewAssembler.Builder.Factory @@ -143,7 +144,8 @@ class StatePlayerRecyclerViewAssembler private constructor( backgroundCoroutineDispatcher: CoroutineDispatcher, private val hasConversationView: Boolean, private val resourceHandler: AppLanguageResourceHandler, - private val translationController: TranslationController + private val translationController: TranslationController, + private var userAnswerState: UserAnswerState ) : HtmlParser.CustomOppiaTagActionListener { /** * A list of view models corresponding to past view models that are hidden by default. These are @@ -323,10 +325,16 @@ class StatePlayerRecyclerViewAssembler private constructor( hasPreviousButton, isSplitView.get()!!, writtenTranslationContext, - timeToStartNoticeAnimationMs + timeToStartNoticeAnimationMs, + userAnswerState ) } + /** Reset userAnswerState once the user submits an answer. */ + fun resetUserAnswerState() { + userAnswerState = UserAnswerState.getDefaultInstance() + } + private fun addContentItem( pendingItemList: MutableList, ephemeralState: EphemeralState, @@ -904,7 +912,8 @@ class StatePlayerRecyclerViewAssembler private constructor( private val resourceHandler: AppLanguageResourceHandler, private val translationController: TranslationController, private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory, - private val singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory + private val singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory, + private val userAnswerState: UserAnswerState ) { private val adapterBuilder: BindableAdapter.MultiTypeBuilder) -> InteractionAnswerHandler? + ): UserAnswerState { + return retrieveAnswerHandler(getAnswerItemList())?.getUserAnswerState() + ?: UserAnswerState.getDefaultInstance() + } + private fun getPendingAnswerWithoutError( answerHandler: InteractionAnswerHandler? ): UserAnswer? { diff --git a/app/src/main/java/org/oppia/android/app/player/state/answerhandling/InteractionAnswerHandler.kt b/app/src/main/java/org/oppia/android/app/player/state/answerhandling/InteractionAnswerHandler.kt index 591a01d10ce..5f7458efbc8 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/answerhandling/InteractionAnswerHandler.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/answerhandling/InteractionAnswerHandler.kt @@ -1,6 +1,8 @@ package org.oppia.android.app.player.state.answerhandling +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState /** * A handler for interaction answers. Handlers can either require an additional user action before the answer can be @@ -26,6 +28,11 @@ interface InteractionAnswerHandler { fun getPendingAnswer(): UserAnswer? { return null } + + /** Returns the current pending answer. */ + fun getUserAnswerState(): UserAnswerState { + return UserAnswerState.getDefaultInstance() + } } /** @@ -35,11 +42,3 @@ interface InteractionAnswerHandler { interface InteractionAnswerReceiver { fun onAnswerReadyForSubmission(answer: UserAnswer) } - -/** Categories of errors that can be inferred from a pending answer. */ -enum class AnswerErrorCategory { - /** Corresponds to errors that may be found while the user is trying to input an answer. */ - REAL_TIME, - /** Corresponds to errors that may be found only when a user tries to submit an answer. */ - SUBMIT_TIME -} diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt index 2200ad829a1..eb08cf630d0 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt @@ -4,6 +4,7 @@ import androidx.fragment.app.Fragment import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler @@ -57,7 +58,8 @@ class ContinueInteractionViewModel private constructor( hasPreviousButton: Boolean, isSplitView: Boolean, writtenTranslationContext: WrittenTranslationContext, - timeToStartNoticeAnimationMs: Long? + timeToStartNoticeAnimationMs: Long?, + userAnswerState: UserAnswerState ): StateItemViewModel { return ContinueInteractionViewModel( interactionAnswerReceiver, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt index d6f88ea9cec..aa205417099 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt @@ -5,6 +5,7 @@ import androidx.databinding.Observable import androidx.databinding.ObservableField import androidx.recyclerview.widget.RecyclerView import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.ListOfSetsOfHtmlStrings @@ -14,8 +15,8 @@ import org.oppia.android.app.model.StringList import org.oppia.android.app.model.SubtitledHtml import org.oppia.android.app.model.TranslatableHtmlContentId import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.model.WrittenTranslationContext -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -163,6 +164,7 @@ class DragAndDropSortInteractionViewModel private constructor( AnswerErrorCategory.REAL_TIME -> null AnswerErrorCategory.SUBMIT_TIME -> getSubmitTimeError().getErrorMessageFromStringRes(resourceHandler) + else -> null } errorMessage.set(pendingAnswerError) return pendingAnswerError @@ -250,7 +252,8 @@ class DragAndDropSortInteractionViewModel private constructor( hasPreviousButton: Boolean, isSplitView: Boolean, writtenTranslationContext: WrittenTranslationContext, - timeToStartNoticeAnimationMs: Long? + timeToStartNoticeAnimationMs: Long?, + userAnswerState: UserAnswerState ): StateItemViewModel { return DragAndDropSortInteractionViewModel( entityId, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt index 193248effe7..b09059e8086 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt @@ -5,12 +5,13 @@ import android.text.TextWatcher import androidx.databinding.Observable import androidx.databinding.ObservableField import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.parser.FractionParsingUiError -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -27,10 +28,12 @@ class FractionInteractionViewModel private constructor( private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, private val writtenTranslationContext: WrittenTranslationContext, private val resourceHandler: AppLanguageResourceHandler, - private val translationController: TranslationController + private val translationController: TranslationController, + userAnswerState: UserAnswerState ) : StateItemViewModel(ViewType.FRACTION_INPUT_INTERACTION), InteractionAnswerHandler { private var pendingAnswerError: String? = null - var answerText: CharSequence = "" + var answerText: CharSequence = userAnswerState.textInputAnswer + private var answerErrorCetegory: AnswerErrorCategory = AnswerErrorCategory.NO_ERROR var isAnswerAvailable = ObservableField(false) var errorMessage = ObservableField("") @@ -54,6 +57,7 @@ class FractionInteractionViewModel private constructor( /* pendingAnswerError= */null, /* inputAnswerAvailable= */true ) + checkPendingAnswerError(userAnswerState.answerErrorCategory) } override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { @@ -69,6 +73,7 @@ class FractionInteractionViewModel private constructor( /** It checks the pending error for the current fraction input, and correspondingly updates the error string based on the specified error category. */ override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { + answerErrorCetegory = category when (category) { AnswerErrorCategory.REAL_TIME -> { if (answerText.isNotEmpty()) { @@ -86,6 +91,7 @@ class FractionInteractionViewModel private constructor( fractionParser.getSubmitTimeError(answerText.toString()) ).getErrorMessageFromStringRes(resourceHandler) } + else -> {} } errorMessage.set(pendingAnswerError) return pendingAnswerError @@ -110,6 +116,13 @@ class FractionInteractionViewModel private constructor( } } + override fun getUserAnswerState(): UserAnswerState { + return UserAnswerState.newBuilder().apply { + this.textInputAnswer = answerText.toString() + this.answerErrorCategory = answerErrorCetegory + }.build() + } + private fun deriveHintText(interaction: Interaction): CharSequence { // The subtitled unicode can apparently exist in the structure in two different formats. val placeholderUnicodeOption1 = @@ -149,7 +162,8 @@ class FractionInteractionViewModel private constructor( hasPreviousButton: Boolean, isSplitView: Boolean, writtenTranslationContext: WrittenTranslationContext, - timeToStartNoticeAnimationMs: Long? + timeToStartNoticeAnimationMs: Long?, + userAnswerState: UserAnswerState ): StateItemViewModel { return FractionInteractionViewModel( interaction, @@ -158,7 +172,8 @@ class FractionInteractionViewModel private constructor( answerErrorReceiver, writtenTranslationContext, resourceHandler, - translationController + translationController, + userAnswerState ) } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt index 4ba33e25422..aa56b9548e9 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt @@ -4,13 +4,14 @@ import androidx.annotation.StringRes import androidx.databinding.Observable import androidx.databinding.ObservableField import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.ClickOnImage import org.oppia.android.app.model.ImageWithRegions import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.model.WrittenTranslationContext -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -102,6 +103,7 @@ class ImageRegionSelectionInteractionViewModel private constructor( ).getErrorMessageFromStringRes(resourceHandler) } } + else -> {} } errorMessage.set(pendingAnswerError) @@ -192,7 +194,8 @@ class ImageRegionSelectionInteractionViewModel private constructor( hasPreviousButton: Boolean, isSplitView: Boolean, writtenTranslationContext: WrittenTranslationContext, - timeToStartNoticeAnimationMs: Long? + timeToStartNoticeAnimationMs: Long?, + userAnswerState: UserAnswerState ): StateItemViewModel { return ImageRegionSelectionInteractionViewModel( entityId, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt index 06b6bb3a47c..9f6bd6682bf 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt @@ -6,14 +6,15 @@ import androidx.annotation.StringRes import androidx.databinding.Observable import androidx.databinding.ObservableField import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.MathEquation import org.oppia.android.app.model.MathExpression import org.oppia.android.app.model.OppiaLanguage import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.model.WrittenTranslationContext -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -64,7 +65,8 @@ class MathExpressionInteractionsViewModel private constructor( private val resourceHandler: AppLanguageResourceHandler, private val translationController: TranslationController, private val mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil, - private val interactionType: InteractionType + private val interactionType: InteractionType, + userAnswerState: UserAnswerState ) : StateItemViewModel(interactionType.viewType), InteractionAnswerHandler { private var pendingAnswerError: String? = null @@ -72,7 +74,7 @@ class MathExpressionInteractionsViewModel private constructor( * Defines the current answer text being entered by the learner. This is expected to be directly * bound to the corresponding edit text. */ - var answerText: CharSequence = "" + var answerText: CharSequence = userAnswerState.textInputAnswer // The value of ths field is set from the Binding and from the TextWatcher. Any // programmatic modification needs to be done here, so that the Binding and the TextWatcher // do not step on each other. @@ -80,6 +82,8 @@ class MathExpressionInteractionsViewModel private constructor( field = value.toString().trim() } + private var answerErrorCetegory: AnswerErrorCategory = AnswerErrorCategory.NO_ERROR + /** * Defines whether an answer is currently available to parse. This is expected to be directly * bound to the UI. @@ -117,6 +121,14 @@ class MathExpressionInteractionsViewModel private constructor( pendingAnswerError = null, inputAnswerAvailable = true ) + checkPendingAnswerError(userAnswerState.answerErrorCategory) + } + + override fun getUserAnswerState(): UserAnswerState { + return UserAnswerState.newBuilder().apply { + this.textInputAnswer = answerText.toString() + this.answerErrorCategory = answerErrorCetegory + }.build() } override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { @@ -153,6 +165,7 @@ class MathExpressionInteractionsViewModel private constructor( }.build() override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { + answerErrorCetegory = category pendingAnswerError = when (category) { // There's no support for real-time errors. AnswerErrorCategory.REAL_TIME -> null @@ -161,6 +174,7 @@ class MathExpressionInteractionsViewModel private constructor( answerText.toString(), allowedVariables, resourceHandler ) } + else -> null } errorMessage.set(pendingAnswerError) return pendingAnswerError @@ -241,7 +255,8 @@ class MathExpressionInteractionsViewModel private constructor( hasPreviousButton: Boolean, isSplitView: Boolean, writtenTranslationContext: WrittenTranslationContext, - timeToStartNoticeAnimationMs: Long? + timeToStartNoticeAnimationMs: Long?, + userAnswerState: UserAnswerState ): StateItemViewModel { return MathExpressionInteractionsViewModel( interaction, @@ -251,7 +266,8 @@ class MathExpressionInteractionsViewModel private constructor( resourceHandler, translationController, mathExpressionAccessibilityUtil, - interactionType + interactionType, + userAnswerState ) } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt index 04174714b4f..347c2d5ccc7 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt @@ -4,12 +4,13 @@ import android.text.Editable import android.text.TextWatcher import androidx.databinding.Observable import androidx.databinding.ObservableField +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.parser.StringToNumberParser -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -22,9 +23,11 @@ class NumericInputViewModel private constructor( private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length val isSplitView: Boolean, private val writtenTranslationContext: WrittenTranslationContext, - private val resourceHandler: AppLanguageResourceHandler + private val resourceHandler: AppLanguageResourceHandler, + userAnswerState: UserAnswerState ) : StateItemViewModel(ViewType.NUMERIC_INPUT_INTERACTION), InteractionAnswerHandler { - var answerText: CharSequence = "" + var answerText: CharSequence = userAnswerState.textInputAnswer + private var answerErrorCetegory: AnswerErrorCategory = AnswerErrorCategory.NO_ERROR private var pendingAnswerError: String? = null val errorMessage = ObservableField("") var isAnswerAvailable = ObservableField(false) @@ -48,6 +51,7 @@ class NumericInputViewModel private constructor( pendingAnswerError = null, inputAnswerAvailable = true ) + checkPendingAnswerError(userAnswerState.answerErrorCategory) } /** @@ -55,6 +59,7 @@ class NumericInputViewModel private constructor( * error string based on the specified error category. */ override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { + answerErrorCetegory = category pendingAnswerError = when (category) { AnswerErrorCategory.REAL_TIME -> if (answerText.isNotEmpty()) @@ -64,11 +69,19 @@ class NumericInputViewModel private constructor( AnswerErrorCategory.SUBMIT_TIME -> stringToNumberParser.getSubmitTimeError(answerText.toString()) .getErrorMessageFromStringRes(resourceHandler) + else -> null } errorMessage.set(pendingAnswerError) return pendingAnswerError } + override fun getUserAnswerState(): UserAnswerState { + return UserAnswerState.newBuilder().apply { + this.textInputAnswer = answerText.toString() + this.answerErrorCategory = answerErrorCetegory + }.build() + } + fun getAnswerTextWatcher(): TextWatcher { return object : TextWatcher { override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { @@ -112,14 +125,16 @@ class NumericInputViewModel private constructor( hasPreviousButton: Boolean, isSplitView: Boolean, writtenTranslationContext: WrittenTranslationContext, - timeToStartNoticeAnimationMs: Long? + timeToStartNoticeAnimationMs: Long?, + userAnswerState: UserAnswerState ): StateItemViewModel { return NumericInputViewModel( hasConversationView, answerErrorReceiver, isSplitView, writtenTranslationContext, - resourceHandler + resourceHandler, + userAnswerState ) } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt index f5c0f323bec..215dfb811ff 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt @@ -5,12 +5,13 @@ import android.text.TextWatcher import androidx.databinding.Observable import androidx.databinding.ObservableField import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.parser.StringToRatioParser -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -28,10 +29,12 @@ class RatioExpressionInputInteractionViewModel private constructor( private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, private val writtenTranslationContext: WrittenTranslationContext, private val resourceHandler: AppLanguageResourceHandler, - private val translationController: TranslationController + private val translationController: TranslationController, + userAnswerState: UserAnswerState ) : StateItemViewModel(ViewType.RATIO_EXPRESSION_INPUT_INTERACTION), InteractionAnswerHandler { private var pendingAnswerError: String? = null - var answerText: CharSequence = "" + var answerText: CharSequence = userAnswerState.textInputAnswer + private var answerErrorCetegory: AnswerErrorCategory = AnswerErrorCategory.NO_ERROR var isAnswerAvailable = ObservableField(false) var errorMessage = ObservableField("") @@ -58,6 +61,7 @@ class RatioExpressionInputInteractionViewModel private constructor( pendingAnswerError = null, inputAnswerAvailable = true ) + checkPendingAnswerError(userAnswerState.answerErrorCategory) } override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { @@ -78,6 +82,7 @@ class RatioExpressionInputInteractionViewModel private constructor( * updates the error string based on the specified error category. */ override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { + answerErrorCetegory = category pendingAnswerError = when (category) { AnswerErrorCategory.REAL_TIME -> if (answerText.isNotEmpty()) @@ -89,11 +94,19 @@ class RatioExpressionInputInteractionViewModel private constructor( answerText.toString(), numberOfTerms = numberOfTerms ).getErrorMessageFromStringRes(resourceHandler) + else -> null } errorMessage.set(pendingAnswerError) return pendingAnswerError } + override fun getUserAnswerState(): UserAnswerState { + return UserAnswerState.newBuilder().apply { + this.textInputAnswer = answerText.toString() + this.answerErrorCategory = answerErrorCetegory + }.build() + } + fun getAnswerTextWatcher(): TextWatcher { return object : TextWatcher { override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { @@ -148,7 +161,8 @@ class RatioExpressionInputInteractionViewModel private constructor( hasPreviousButton: Boolean, isSplitView: Boolean, writtenTranslationContext: WrittenTranslationContext, - timeToStartNoticeAnimationMs: Long? + timeToStartNoticeAnimationMs: Long?, + userAnswerState: UserAnswerState ): StateItemViewModel { return RatioExpressionInputInteractionViewModel( interaction, @@ -157,7 +171,8 @@ class RatioExpressionInputInteractionViewModel private constructor( answerErrorReceiver, writtenTranslationContext, resourceHandler, - translationController + translationController, + userAnswerState ) } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt index af2d7e0f6cc..d5f4b7016c7 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt @@ -6,14 +6,16 @@ import androidx.databinding.ObservableBoolean import androidx.databinding.ObservableField import androidx.databinding.ObservableList import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject +import org.oppia.android.app.model.ItemSelectionAnswerState import org.oppia.android.app.model.SetOfTranslatableHtmlContentIds import org.oppia.android.app.model.SubtitledHtml import org.oppia.android.app.model.TranslatableHtmlContentId import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.model.WrittenTranslationContext -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -49,7 +51,8 @@ class SelectionInteractionViewModel private constructor( val isSplitView: Boolean, val writtenTranslationContext: WrittenTranslationContext, private val translationController: TranslationController, - private val resourceHandler: AppLanguageResourceHandler + private val resourceHandler: AppLanguageResourceHandler, + userAnswerState: UserAnswerState ) : StateItemViewModel(ViewType.SELECTION_INTERACTION), InteractionAnswerHandler { private val interactionId: String = interaction.id @@ -60,6 +63,9 @@ class SelectionInteractionViewModel private constructor( ?.map { schemaObject -> schemaObject.customSchemaValue.subtitledHtml } ?: listOf() } + + private var answerErrorCetegory: AnswerErrorCategory = AnswerErrorCategory.NO_ERROR + private val minAllowableSelectionCount: Int by lazy { interaction.customizationArgsMap["minAllowableSelectionCount"]?.signedInt ?: 1 } @@ -106,6 +112,27 @@ class SelectionInteractionViewModel private constructor( pendingAnswerError = null, inputAnswerAvailable = true ) + + if (userAnswerState.itemSelection.selectedIndexesCount != 0) { + userAnswerState.itemSelection.selectedIndexesList.forEach { selectedIndex -> + selectedItems += selectedIndex + choiceItems[selectedIndex].isAnswerSelected.set(true) + } + updateItemSelectability() + updateSelectionText() + updateIsAnswerAvailable() + } + + checkPendingAnswerError(userAnswerState.answerErrorCategory) + } + + override fun getUserAnswerState(): UserAnswerState { + return UserAnswerState.newBuilder().apply { + this.itemSelection = ItemSelectionAnswerState.newBuilder().addAllSelectedIndexes( + selectedItems + ).build() + this.answerErrorCategory = answerErrorCetegory + }.build() } override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { @@ -141,10 +168,14 @@ class SelectionInteractionViewModel private constructor( * updates the error string based on the specified error category. */ override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { + answerErrorCetegory = category pendingAnswerError = when (category) { - AnswerErrorCategory.REAL_TIME -> null + AnswerErrorCategory.REAL_TIME -> { + null + } AnswerErrorCategory.SUBMIT_TIME -> getSubmitTimeError().getErrorMessageFromStringRes(resourceHandler) + else -> null } errorMessage.set(pendingAnswerError) return pendingAnswerError @@ -267,7 +298,8 @@ class SelectionInteractionViewModel private constructor( hasPreviousButton: Boolean, isSplitView: Boolean, writtenTranslationContext: WrittenTranslationContext, - timeToStartNoticeAnimationMs: Long? + timeToStartNoticeAnimationMs: Long?, + userAnswerState: UserAnswerState ): StateItemViewModel { return SelectionInteractionViewModel( entityId, @@ -277,7 +309,8 @@ class SelectionInteractionViewModel private constructor( isSplitView, writtenTranslationContext, translationController, - resourceHandler + resourceHandler, + userAnswerState ) } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt index 3d93249cf7c..418dec9383c 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt @@ -1,6 +1,7 @@ package org.oppia.android.app.player.state.itemviewmodel import org.oppia.android.app.model.Interaction +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -58,7 +59,8 @@ abstract class StateItemViewModel(val viewType: ViewType) : ObservableViewModel( hasPreviousButton: Boolean, isSplitView: Boolean, writtenTranslationContext: WrittenTranslationContext, - timeToStartNoticeAnimationMs: Long? + timeToStartNoticeAnimationMs: Long?, + userAnswerState: UserAnswerState = UserAnswerState.getDefaultInstance() ): StateItemViewModel } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt index 7685e3673b9..88082ac39b3 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt @@ -6,11 +6,12 @@ import androidx.annotation.StringRes import androidx.databinding.Observable import androidx.databinding.ObservableField import org.oppia.android.R +import org.oppia.android.app.model.AnswerErrorCategory import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState import org.oppia.android.app.model.WrittenTranslationContext -import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -26,9 +27,11 @@ class TextInputViewModel private constructor( val isSplitView: Boolean, private val writtenTranslationContext: WrittenTranslationContext, private val resourceHandler: AppLanguageResourceHandler, - private val translationController: TranslationController + private val translationController: TranslationController, + userAnswerState: UserAnswerState ) : StateItemViewModel(ViewType.TEXT_INPUT_INTERACTION), InteractionAnswerHandler { - var answerText: CharSequence = "" + var answerText: CharSequence = userAnswerState.textInputAnswer + private var answerErrorCetegory: AnswerErrorCategory = AnswerErrorCategory.NO_ERROR val hintText: CharSequence = deriveHintText(interaction) private var pendingAnswerError: String? = null @@ -53,9 +56,11 @@ class TextInputViewModel private constructor( pendingAnswerError = null, inputAnswerAvailable = true ) + checkPendingAnswerError(userAnswerState.answerErrorCategory) } override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { + answerErrorCetegory = category return when (category) { AnswerErrorCategory.REAL_TIME -> null AnswerErrorCategory.SUBMIT_TIME -> { @@ -63,6 +68,7 @@ class TextInputViewModel private constructor( answerText.toString() ).createForText(resourceHandler) } + else -> null }.also { pendingAnswerError = it errorMessage.set(it) @@ -99,6 +105,13 @@ class TextInputViewModel private constructor( } }.build() + override fun getUserAnswerState(): UserAnswerState { + return UserAnswerState.newBuilder().apply { + this.textInputAnswer = answerText.toString() + this.answerErrorCategory = answerErrorCetegory + }.build() + } + private fun deriveHintText(interaction: Interaction): CharSequence { // The subtitled unicode can apparently exist in the structure in two different formats. val placeholderUnicodeOption1 = @@ -134,7 +147,8 @@ class TextInputViewModel private constructor( hasPreviousButton: Boolean, isSplitView: Boolean, writtenTranslationContext: WrittenTranslationContext, - timeToStartNoticeAnimationMs: Long? + timeToStartNoticeAnimationMs: Long?, + userAnswerState: UserAnswerState ): StateItemViewModel { return TextInputViewModel( interaction, @@ -143,7 +157,8 @@ class TextInputViewModel private constructor( isSplitView, writtenTranslationContext, resourceHandler, - translationController + translationController, + userAnswerState ) } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt index e8117516284..2fc44ad11f5 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt @@ -71,6 +71,7 @@ class StateFragmentTestActivity : fun createTestActivityIntent( context: Context, profileId: Int, + classroomId: String, topicId: String, storyId: String, explorationId: String, @@ -78,6 +79,7 @@ class StateFragmentTestActivity : ): Intent { val args = StateFragmentTestActivityParams.newBuilder().apply { this.internalProfileId = profileId + this.classroomId = classroomId this.topicId = topicId this.storyId = storyId this.explorationId = explorationId diff --git a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt index 3c52bb6e777..6beed9aaf21 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivityPresenter.kt @@ -12,6 +12,7 @@ import org.oppia.android.app.player.exploration.TAG_HINTS_AND_SOLUTION_EXPLORATI import org.oppia.android.app.player.state.StateFragment import org.oppia.android.app.player.state.testing.StateFragmentTestActivity.Companion.STATE_FRAGMENT_TEST_ACTIVITY_PARAMS_KEY import org.oppia.android.databinding.StateFragmentTestActivityBinding +import org.oppia.android.domain.classroom.TEST_CLASSROOM_ID_0 import org.oppia.android.domain.exploration.ExplorationDataController import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_2 @@ -34,6 +35,7 @@ class StateFragmentTestActivityPresenter @Inject constructor( ) { private var profileId: Int = 1 + private lateinit var classroomId: String private lateinit var topicId: String private lateinit var storyId: String private lateinit var explorationId: String @@ -54,6 +56,7 @@ class StateFragmentTestActivityPresenter @Inject constructor( StateFragmentTestActivityParams.getDefaultInstance() ) profileId = args?.internalProfileId ?: 1 + classroomId = args?.classroomId ?: TEST_CLASSROOM_ID_0 topicId = args?.topicId ?: TEST_TOPIC_ID_0 storyId = @@ -63,7 +66,14 @@ class StateFragmentTestActivityPresenter @Inject constructor( ?: TEST_EXPLORATION_ID_2 shouldSavePartialProgress = args?.shouldSavePartialProgress ?: false activity.findViewById