From 5427c113f07c7d6e8525bd67a4ac93f719d4e77e Mon Sep 17 00:00:00 2001 From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com> Date: Tue, 27 Jun 2023 19:04:24 -0600 Subject: [PATCH 01/33] Fix #5072 : Remove condition of hex color for drawables from regex (#5076) ## Explanation Fix https://github.com/oppia/oppia-android/issues/5072 : Remove condition of hex color for drawables from regex ## Essential Checklist - [x] The PR title and explanation each start with "Fix #bugnum: " (If this PR fixes part of an issue, prefix the title with "Fix part of #bugnum: ...".) - [x] Any changes to [scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets) files have their rationale included in the PR explanation. - [x] The PR follows the [style guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide). - [x] The PR does not contain any unnecessary code changes from Android Studio ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)). - [x] The PR is made from a branch that's **not** called "develop" and is up-to-date with "develop". - [x] The PR is **assigned** to the appropriate reviewers ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)). ## For UI-specific PRs only If your PR includes UI-related changes, then: - Add screenshots for portrait/landscape for both a tablet & phone of the before & after UI changes - For the screenshots above, include both English and pseudo-localized (RTL) screenshots (see [RTL guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines)) - Add a video showing the full UX flow with a screen reader enabled (see [accessibility guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide)) - Add a screenshot demonstrating that you ran affected Espresso tests locally & that they're passing --- .../file_content_validation_checks.textproto | 11 ------- .../regex/RegexPatternValidationCheckTest.kt | 32 +------------------ 2 files changed, 1 insertion(+), 42 deletions(-) diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index ab9c8af53ec..40a355c58f2 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -352,17 +352,6 @@ file_content_checks { prohibited_content_regex: "@color/(?!component_color_).+|\"#\\p{XDigit}+\"" failure_message: "Only colors from component_colors.xml may be used in layouts." } -file_content_checks { - file_path_regex: "app/src/main/res/drawable.*?/.+?\\.xml" - prohibited_content_regex: "@color/(?!component_color_).+|\"#\\p{XDigit}+\"" - failure_message: "Only colors from component_colors.xml may be used in drawables except vector assets." - exempted_file_patterns: "app/src/main/res/drawable.*?/(ic_|lesson_thumbnail_graphic_).+?\\.xml" - exempted_file_patterns: "app/src/main/res/drawable/full_oppia_logo.xml" - exempted_file_patterns: "app/src/main/res/drawable/rounded_white_background_with_shadow.xml" - exempted_file_patterns: "app/src/main/res/drawable/profile_image_shadow.xml" - exempted_file_patterns: "app/src/main/res/drawable/selected_region_background.xml" - exempted_file_patterns: "app/src/main/res/drawable/splash_page.xml" -} file_content_checks { file_path_regex: "app/src/main/java/org/oppia/android/app.?/.+(ActivityPresenter|FragmentPresenter|ViewPresenter|Activity|Fragment|View)\\.kt" prohibited_content_regex: "R.color.(?!component_color_).+" diff --git a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt index 20fd219f857..ae1d4fa60ff 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt @@ -173,8 +173,6 @@ class RegexPatternValidationCheckTest { "Only colors from color_defs.xml may be used in color_palette.xml." private val doesNotReferenceColorFromComponentColorInLayouts = "Only colors from component_colors.xml may be used in layouts." - private val doesNotReferenceColorFromComponentColorInDrawables = - "Only colors from component_colors.xml may be used in drawables except vector assets." private val doesNotReferenceColorFromComponentColorInKotlinFiles = "Only colors from component_colors.xml may be used in Kotlin Files (Activities, Fragments, " + "Views and Presenters)." @@ -2345,35 +2343,7 @@ class RegexPatternValidationCheckTest { ) } - @Test - fun testFileContent_xmlDrawables_includesNonColorComponentReferences_fileContentIsNotCorrect() { - val prohibitedContent = - """ - android:color="@color/component_color_shared_primary_text_color" - android:color="@color/color_defs_shared_primary_text_color" - android:color="@color/color_palette_primary_text_color" - android:color="#003933" - """.trimIndent() - tempFolder.newFolder("testfiles", "app", "src", "main", "res", "drawable") - val stringFilePath = "app/src/main/res/drawable/test_layout.xml" - tempFolder.newFile("testfiles/$stringFilePath").writeText(prohibitedContent) - - val exception = assertThrows(Exception::class) { - runScript() - } - - // Verify that all patterns are properly detected & prohibited. - assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) - assertThat(outContent.toString().trim()) - .isEqualTo( - """ - $stringFilePath:2: $doesNotReferenceColorFromComponentColorInDrawables - $stringFilePath:3: $doesNotReferenceColorFromComponentColorInDrawables - $stringFilePath:4: $doesNotReferenceColorFromComponentColorInDrawables - $wikiReferenceNote - """.trimIndent() - ) - } + // TODO(#5075): Add test for drawables file that checks color uses only component colors @Test fun testFileContent_kotlinFiles_includesNonColorComponentReferences_fileContentIsNotCorrect() { From d049a5a8ba2423802391475f355f99880b479c48 Mon Sep 17 00:00:00 2001 From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com> Date: Fri, 30 Jun 2023 06:07:29 -0600 Subject: [PATCH 02/33] [Android Wiki] Fix Part of #2746 : Added Bazel Installations instructions for different Operating System (#4926) ## Explanation Fix Part of #2746 : Added Bazel Installations instructions for different Operating System ## Essential Checklist - [x] The PR title and explanation each start with "Fix #bugnum: " (If this PR fixes part of an issue, prefix the title with "Fix part of #bugnum: ...".) - [x] Any changes to [scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets) files have their rationale included in the PR explanation. - [x] The PR follows the [style guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide). - [x] The PR does not contain any unnecessary code changes from Android Studio ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)). - [x] The PR is made from a branch that's **not** called "develop" and is up-to-date with "develop". - [x] The PR is **assigned** to the appropriate reviewers ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)). --- wiki/Bazel-Setup-Instructions-for-Linux.md | 66 ++++++++++++ wiki/Bazel-Setup-Instructions-for-Mac.md | 103 +++++++++++++++++++ wiki/Bazel-Setup-Instructions-for-Windows.md | 16 +++ wiki/Oppia-Bazel-Setup-Instructions.md | 39 ++----- 4 files changed, 192 insertions(+), 32 deletions(-) create mode 100644 wiki/Bazel-Setup-Instructions-for-Linux.md create mode 100644 wiki/Bazel-Setup-Instructions-for-Mac.md diff --git a/wiki/Bazel-Setup-Instructions-for-Linux.md b/wiki/Bazel-Setup-Instructions-for-Linux.md new file mode 100644 index 00000000000..df69862fa88 --- /dev/null +++ b/wiki/Bazel-Setup-Instructions-for-Linux.md @@ -0,0 +1,66 @@ +## Instructions + +**The steps to install Bazel on Linux are:** +1. Install Bazel +2. Install OpenJDK 8 +3. Install Python 2 and make sure it is active in your environment +4. Set up the ANDROID_HOME environment variable +5. Prepare the build environment +6. Verify the Android build + +### 1. Install Bazel + +Install Bazel from [here](https://docs.bazel.build/versions/master/install.html). Make sure that you follow the instructions for installing a specific version (Oppia Android requires 4.0.0 and won't build on other versions). + - Note: if you find any errors related to `cURL`, please set up cURL on your machine. For Linux, you can use `sudo apt install curl`. + +### 2. Install OpenJDK 8 + +Oppia Android also requires OpenJDK 8. The Bazel installation instructions above include [sections on installing OpenJDK](https://docs.bazel.build/versions/main/tutorial/java.html#install-the-jdk) on different platforms. + + - You can run the following to install OpenJDK 8: + + ```sh + sudo apt install openjdk-8-jdk + ``` + + You can confirm that this is set up using the command `java -version`, which should result in three lines being printed out with the first one showing "openjdk version "1.8.0_292". + +### 3. Install Python 2 + +Ensure that you have Python 2 installed and make sure that it is currently active on your environment. You can do this by using the ``python --version`` command which should show Python 2.X.X. If it doesn’t, click [here](https://linuxconfig.org/install-python-2-on-ubuntu-20-04-focal-fossa-linux) for a resource on how to install and update Linux to use Python 2. + +### 4. Set up the ANDROID_HOME environment variable + +Ensure that your `ANDROID_HOME` environment variable is set to the location of your Android SDK. To do this, find the path to the installed SDK using Android Studio’s SDK Manager (install SDK 28). Assuming the SDK is installed to default locations, you can use the following commands to set the `ANDROID_HOME` variable:
+ ``` + export ANDROID_HOME=$HOME/Android/Sdk/ + ``` + - **Make sure you have the system environment variable set up** for ``ANDROID_HOME`` as you might have issues getting properly set up if not. If it isn’t set up (on Linux you can check by using ``echo $ANDROID_HOME`` in a new terminal; it should output the correct path to your Android SDK), on Linux you can move the ``export`` from above to your ``~/.bashrc`` file to make it permanent (you can apply the change immediately using ``source ~/.bashrc``). + +### 5. Prepare the build environment + +Follow the instructions in [oppia-bazel-tools](https://github.com/oppia/oppia-bazel-tools). + +### 6. Verifying the build + +At this point, your system should be able to build Oppia Android. To verify, try building the APK (from your subsystem terminal -- note that this and all other Bazel commands must be run from the root of the ‘oppia-android’ directory otherwise they will fail): + +```sh +bazel build //:oppia +``` + +(Note that this command may take 10-20 minutes to complete depending on the performance of your machine). + +If everything is working, you should see output like the following: + +``` +Target //:oppia up-to-date: + bazel-bin/oppia_deploy.jar + bazel-bin/oppia_unsigned/apk + bazel-bin/oppia/apk +INFO: Elapsed time: ... +INFO: 1 process... +INFO: Build completed successfully, ... +``` + +Note also that the ``oppia.apk`` under the ``bazel-bin`` directory of your local copy of Oppia Android should be a fully functioning development version of the app that can be installed using ``adb`` diff --git a/wiki/Bazel-Setup-Instructions-for-Mac.md b/wiki/Bazel-Setup-Instructions-for-Mac.md new file mode 100644 index 00000000000..11fd0116f8b --- /dev/null +++ b/wiki/Bazel-Setup-Instructions-for-Mac.md @@ -0,0 +1,103 @@ +## Bazel Set up for Mac including Apple silicon (M1/M2) chips + +## Instructions + +**The steps to install Bazel on Mac are:** +1. Set up Rosetta Terminal +2. Install Bazel +3. Install OpenJDK 8 +4. Install Python 2 and make sure it is active in your environment +5. Set Bazel, Python 2, ANDROID_HOME paths permanently in your terminal +6. Prepare the build environment +7. Verify that the build is working + +### 1. Set up Rosetta Terminal + +- In the Finder app on your Mac, locate the Applications folder from the favorites sidebar. +- Right-click on your Terminal app and create a duplicate Terminal (and rename it accordingly, say **Terminal Rosetta**, to avoid confusion). +- On the newly created Terminal Rosetta icon, right-click and select "Get info", and under “General”, check the option "Open using Rosetta". + +**Note: Always use the Rosetta terminal for Bazel setup and running `bash setup.sh` or any Bazel build-related commands.** + +### 2. Install Bazel + +1. Install Bazel following the instructions [here](https://docs.bazel.build/versions/4.0.0/install-os-x.html#install-with-installer-mac-os-x). Make sure that you follow the instructions for installing a specific version (Oppia Android requires 4.0.0 and won't build on other versions). + +2. That’s it, now Bazel is installed, and you can verify it by running the command: + ``` + bazel --version + ``` + - **Expected Output** + ``` + bazel 4.0.0 + ``` + +### 3. Install OpenJDK 8 + +Oppia Android also requires OpenJDK 8. +Follow the instructions [here](https://installvirtual.com/install-openjdk-8-on-mac-using-brew-adoptopenjdk/) to install OpenJDK 8. +Note that this requires the installation of brew as a pre-requisite, which can be done by following the instructions [here](https://mac.install.guide/homebrew/index.html). You can then set up your `$JAVA_HOME` environment variable using these [instructions](https://stackoverflow.com/a/75167958/11396524). + + +### 4. Install Python 2 and make sure it is active in your environment + +To install Python 2 in MacOS follow the follows the commands given below. Note that this requires installation of brew as a pre-requisite, which can be done by following the instructions [here](https://mac.install.guide/homebrew/index.html). +``` +brew install pyenv +pyenv install 2.7.18 +pyenv global 2.7.18 +``` + +- To make sure Python 2 is successfully installed and active in your environment, navigate to the **oppia-android** directory and run the following commands: + +``` +export PATH="$(pyenv root)/shims:${PATH}" +python --version +``` + +### 5. Set Bazel, Python 2, ANDROID_HOME paths permanently in your terminal + +- To set the `Bazel`, `Python 2`, `ANDROID_HOME` path permanently in your terminal run these commands: + ``` + sudo nano /etc/paths + ``` + - Enter your password, when prompted. + - Go to the bottom of the file, and enter these paths + ``` + /Users/{YourMacUserName}/bin + $(pyenv root)/shims:${PATH} + $HOME/Library/Android/sdk + ``` + - Hit control-x to quit. + - Enter “Y” to save the modified buffer. + - That’s it! To test it, in a new terminal window, type: `echo $PATH` + +**Note: You must set the path for `Bazel`, `Python 2`, `ANDROID_HOME` before running bazel build for oppia-android, otherwise you will get an error.** + +### 6. Prepare the build environment + +Follow the instructions in [oppia-bazel-tools](https://github.com/oppia/oppia-bazel-tools#readme), in order to prepare your environment to support Oppia Android builds. + +### 7. Verify that the build is working + +At this point, your system should be able to build Oppia Android. To verify, try building the APK (from your subsystem terminal -- note that this and all other Bazel commands must be run from the root of the ‘oppia-android’ directory otherwise they will fail): + +``` +bazel build //:oppia +``` + +(Note that this command may take 10-20 minutes to complete depending on the performance of your machine). + +If everything is working, you should see output like the following: + +``` +Target //:oppia up-to-date: + bazel-bin/oppia_deploy.jar + bazel-bin/oppia_unsigned/apk + bazel-bin/oppia/apk +INFO: Elapsed time: ... +INFO: 1 process... +INFO: Build completed successfully, ... +``` + +Note also that the ``oppia.apk`` under the ``bazel-bin`` directory of your local copy of Oppia Android should be a fully functioning development version of the app that can be installed using ``adb`` diff --git a/wiki/Bazel-Setup-Instructions-for-Windows.md b/wiki/Bazel-Setup-Instructions-for-Windows.md index 7d757e63e1f..ebf203356df 100644 --- a/wiki/Bazel-Setup-Instructions-for-Windows.md +++ b/wiki/Bazel-Setup-Instructions-for-Windows.md @@ -59,6 +59,13 @@ JDK 8 is required for the Android build tools, and we suggest installing OpenJDK sudo apt install openjdk-8-jdk-headless ``` +#### For Fedora 25+ +- Install JDK 8 by running this command on the terminal: +``` +sudo dnf install java-1.8.0-openjdk +``` +- Set the default Java version to jdk-8 by running the following command `sudo update-alternatives --config java` and selecting the number with jdk-8. + **Python 2** Unfortunately, some of the Bazel build actions in the Android pipeline require Python 2 to be installed: @@ -131,6 +138,15 @@ Follow [these instructions](https://docs.bazel.build/versions/main/install-ubunt sudo apt install bazel-4.0.0 ``` +#### For Fedora 25+ + +- Install Bazelisk instead of Bazel using the command below in Fedora: +``` +wget https://github.com/bazelbuild/bazelisk/releases/download/v1.8.1/bazelisk-linux-amd64 +chmod +x bazelisk-linux-amd64 +sudo mv bazelisk-linux-amd64 /usr/local/bin/bazel +``` + ### 5. Preparing build environment for Oppia Android The Oppia Android repository generally expects to live under an 'opensource' directory. While we recommend doing that in practice, we run into one complication when building the app on Windows: the repository itself lives under the native Windows filesystem & most of everything else needed to build lives under the Linux subsystem. To help simplify things, we prefer keeping just the repository on Windows and everything else on Linux, including the the Oppia Bazel toolchain. To prepare for this, we suggest making an 'opensource' directory in your Ubuntu subsystem: diff --git a/wiki/Oppia-Bazel-Setup-Instructions.md b/wiki/Oppia-Bazel-Setup-Instructions.md index 8805b67c9f8..fe23884521e 100644 --- a/wiki/Oppia-Bazel-Setup-Instructions.md +++ b/wiki/Oppia-Bazel-Setup-Instructions.md @@ -12,44 +12,19 @@ ## Overview Bazel is an open-source build and test tool similar to Make, Maven, and Gradle. It uses a human-readable, high-level build language. -## Installation +### Installation **WARNING: We recommend to not use the Android Studio Bazel plugin since it currently has compatibility issues with the project.** -**NOTE: If you're using Windows, please follow [these instructions](https://github.com/oppia/oppia-android/wiki/Bazel-Setup-Instructions-for-Windows) instead.** +**Instructions for setting up Bazel on different Operating Systems:** -Instructions for setting up Bazel on Unix-based machines: +- [For Windows/Ubuntu/Fedora](https://github.com/oppia/oppia-android/wiki/Bazel-Setup-Instructions-for-Windows) +- [For Mac including M1/M2](https://github.com/oppia/oppia-android/wiki/Bazel-Setup-Instructions-for-Mac) +- [For Linux](https://github.com/oppia/oppia-android/wiki/Bazel-Setup-Instructions-for-Linux) -1. Install Bazel from [here](https://docs.bazel.build/versions/master/install.html). Make sure that you follow the instructions for installing a specific version (Oppia Android requires 4.0.0 and won't build on other versions). - - As of February 2023 we have verified that on Ubuntu (and similar systems) the [apt repository approach](https://bazel.build/install/ubuntu#install-on-ubuntu) works, you just need to make sure to do `sudo apt install bazel-4.0.0` as the latest command to install the correct version. - - Note: if you find any errors related to `cURL`, please set up cURL on your machine. For Linux, you can use `sudo apt install curl`. +### Building the app - -2. Oppia Android also requires OpenJDK 8. The Bazel installation instructions above include [sections on installing OpenJDK](https://docs.bazel.build/versions/main/tutorial/java.html#install-the-jdk) on different platforms. - - - For example, if you're using Ubuntu or another Debian-based system, you can run the following to install OpenJDK 8: - - ```sh - sudo apt install openjdk-8-jdk - ``` - - - For MacOS M1, follow the instructions [here](https://installvirtual.com/install-openjdk-8-on-mac-using-brew-adoptopenjdk/). Note that, this requires installation of brew as a pre-requisite, which can be done by following the instructions [here](https://mac.install.guide/homebrew/index.html). - - You can confirm that this is set up using the command `java -version`, which should result in three lines being printed out with the first one showing "openjdk version "1.8.0_292". - -3. Ensure that you have Python 2 installed and make sure that it is currently active on your environment. You can do this by using the ``python --version`` command which should show Python 2.X.X. If it doesn’t, click [here](https://linuxconfig.org/install-python-2-on-ubuntu-20-04-focal-fossa-linux) for a resource on how to install and update Ubuntu to use Python 2 (other distros may vary in this step slightly). - -4. Ensure that your `ANDROID_HOME` environment variable is set to the location of your Android SDK. To do this, find the path to the installed SDK using Android Studio’s SDK Manager (install SDK 28). Assuming the SDK is installed to default locations, you can use the following commands to set the `ANDROID_HOME` variable:
- - Linux: `export ANDROID_HOME=$HOME/Android/Sdk/`
- - macOS: `export ANDROID_HOME=$HOME/Library/Android/sdk` - - **Make sure you have the system environment variable set up** for ``ANDROID_HOME`` as you might have issues getting properly set up if not. If it isn’t set up (on Linux you can check by using ``echo $ANDROID_HOME`` in a new terminal; it should output the correct path to your Android SDK), on Linux you can move the ``export`` from above to your ``~/.bashrc`` file to make it permanent (you can apply the change immediately using ``source ~/.bashrc``). - -5. Follow the instructions in [oppia-bazel-tools](https://github.com/oppia/oppia-bazel-tools). - - -#### Building the app - -After the installation, completes you can build the app using Bazel. +After the installation completes you can build the app using Bazel. **Move your command line head to the `~/opensource/oppia-android`**, then run the below bazel command: From f93c06bb5a5681922c9796aadd8cb39e355b4832 Mon Sep 17 00:00:00 2001 From: MOHIT GUPTA <76530270+MohitGupta121@users.noreply.github.com> Date: Tue, 4 Jul 2023 04:10:21 -0600 Subject: [PATCH 03/33] Fix #4909 : Remove the unused condition from various lessons_chapters_view for Accessibility (#4910) ## Explanation Fixed #4909 : Remove the unused conditions from `lessons_no_started_chapter_view.xml` `lessons_in_progress_chapter_view.xml` `lessons_locked_chapter_view.xml` for Accessibility. Fixed a bug where TalkBack would read chapter names twice when the chapter is locked, and also improved the feedback read out when a locked chapter is clicked ## Essential Checklist - [x] The PR title and explanation each start with "Fix #bugnum: " (If this PR fixes part of an issue, prefix the title with "Fix part of #bugnum: ...".) - [ ] Any changes to [scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets) files have their rationale included in the PR explanation. - [x] The PR follows the [style guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide). - [x] The PR does not contain any unnecessary code changes from Android Studio ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)). - [x] The PR is made from a branch that's **not** called "develop" and is up-to-date with "develop". - [x] The PR is **assigned** to the appropriate reviewers ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)). ## For UI-specific PRs only If your PR includes UI-related changes, then: - Add screenshots for portrait/landscape for both a tablet & phone of the before & after UI changes - For the screenshots above, include both English and pseudo-localized (RTL) screenshots (see [RTL guide](https://github.com/oppia/oppia-android/wiki/RTL-Guidelines)) - Add a video showing the full UX flow with a screen reader enabled (see [accessibility guide](https://github.com/oppia/oppia-android/wiki/Accessibility-A11y-Guide)) - Add a screenshot demonstrating that you ran affected Espresso tests locally & that they're passing --- .../topic/lessons/ChapterSummaryViewModel.kt | 6 +++- .../lessons_in_progress_chapter_view.xml | 4 +-- .../layout/lessons_locked_chapter_view.xml | 4 +-- .../lessons_not_started_chapter_view.xml | 4 +-- app/src/main/res/values/strings.xml | 1 + .../topic/lessons/TopicLessonsFragmentTest.kt | 32 ++++++++++++------- 6 files changed, 33 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt index 33a0fb04bc4..857bf87d91a 100644 --- a/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt @@ -33,7 +33,11 @@ class ChapterSummaryViewModel( ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES -> { if (previousChapterTitle != null) { resourceHandler.getStringInLocaleWithWrapping( - R.string.chapter_prerequisite_title_label, index.toString(), previousChapterTitle + R.string.chapter_locked_prerequisite_title_label, + (index + 1).toString(), + chapterTitle, + index.toString(), + previousChapterTitle ) } else { resourceHandler.getStringInLocaleWithWrapping( diff --git a/app/src/main/res/layout/lessons_in_progress_chapter_view.xml b/app/src/main/res/layout/lessons_in_progress_chapter_view.xml index 057e550d1d1..dfd3b226eda 100644 --- a/app/src/main/res/layout/lessons_in_progress_chapter_view.xml +++ b/app/src/main/res/layout/lessons_in_progress_chapter_view.xml @@ -34,7 +34,7 @@ android:layout_gravity="center" android:layout_marginStart="10dp" android:fontFamily="sans-serif" - android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}" + android:importantForAccessibility="no" android:minWidth="20dp" android:minHeight="20dp" android:text="@{viewModel.computePlayChapterIndexText()}" @@ -59,7 +59,7 @@ android:background="@drawable/chapter_white_bg_with_bright_green_border" android:fontFamily="sans-serif" android:gravity="center|start" - android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}" + android:importantForAccessibility="no" android:minHeight="48dp" android:paddingStart="12dp" android:paddingEnd="12dp" diff --git a/app/src/main/res/layout/lessons_locked_chapter_view.xml b/app/src/main/res/layout/lessons_locked_chapter_view.xml index 78a04c5938e..c5d8c7d4f7e 100644 --- a/app/src/main/res/layout/lessons_locked_chapter_view.xml +++ b/app/src/main/res/layout/lessons_locked_chapter_view.xml @@ -35,7 +35,7 @@ android:layout_gravity="center" android:layout_marginStart="10dp" android:fontFamily="sans-serif" - android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}" + android:importantForAccessibility="no" android:minWidth="20dp" android:minHeight="20dp" android:text="@{viewModel.computePlayChapterIndexText()}" @@ -65,7 +65,7 @@ android:background="@color/component_color_lessons_tab_activity_lessons_locked_chapter_name_background_color" android:fontFamily="sans-serif" android:gravity="center|start" - android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}" + android:importantForAccessibility="no" android:minHeight="48dp" android:paddingStart="12dp" android:paddingEnd="12dp" diff --git a/app/src/main/res/layout/lessons_not_started_chapter_view.xml b/app/src/main/res/layout/lessons_not_started_chapter_view.xml index f2038b60e8f..2db54e99d00 100644 --- a/app/src/main/res/layout/lessons_not_started_chapter_view.xml +++ b/app/src/main/res/layout/lessons_not_started_chapter_view.xml @@ -30,7 +30,7 @@ android:background="@drawable/chapter_dark_green_bg_with_bright_green_border" android:fontFamily="sans-serif" android:gravity="center" - android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}" + android:importantForAccessibility="no" android:minWidth="60dp" android:minHeight="48dp" android:paddingStart="8dp" @@ -48,7 +48,7 @@ android:layout_height="0dp" android:fontFamily="sans-serif" android:gravity="center|start" - android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}" + android:importantForAccessibility="no" android:minHeight="48dp" android:paddingStart="12dp" android:paddingEnd="12dp" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 370de593631..ed1a4bca9fd 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -90,6 +90,7 @@ Chapter %s with title %s is completed Chapter %s with title %s is in progress Complete Chapter %s: %s to unlock this chapter. + Chapter %s: %s is currently locked. Please complete chapter %s: %s to unlock this chapter. Complete the previous chapter to unlock this chapter. Enter text. Enter a fraction in the form x/x, or a mixed number in the form x x/x. diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt index babec426922..8ea32a5e1ab 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt @@ -152,16 +152,25 @@ import javax.inject.Singleton qualifiers = "port-xxhdpi" ) class TopicLessonsFragmentTest { - @get:Rule val initializeDefaultLocaleRule = InitializeDefaultLocaleRule() - @get:Rule val oppiaTestRule = OppiaTestRule() - - @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers - @Inject lateinit var storyProgressTestHelper: StoryProgressTestHelper - @Inject lateinit var fakeOppiaClock: FakeOppiaClock - @Inject lateinit var fakeAccessibilityService: FakeAccessibilityService - @Inject lateinit var spotlightStateController: SpotlightStateController - @Inject lateinit var explorationCheckpointTestHelper: ExplorationCheckpointTestHelper - @Inject lateinit var fakeExplorationRetriever: FakeExplorationRetriever + @get:Rule + val initializeDefaultLocaleRule = InitializeDefaultLocaleRule() + @get:Rule + val oppiaTestRule = OppiaTestRule() + + @Inject + lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + @Inject + lateinit var storyProgressTestHelper: StoryProgressTestHelper + @Inject + lateinit var fakeOppiaClock: FakeOppiaClock + @Inject + lateinit var fakeAccessibilityService: FakeAccessibilityService + @Inject + lateinit var spotlightStateController: SpotlightStateController + @Inject + lateinit var explorationCheckpointTestHelper: ExplorationCheckpointTestHelper + @Inject + lateinit var fakeExplorationRetriever: FakeExplorationRetriever @field:[Inject EnableExtraTopicTabsUi] lateinit var enableExtraTopicTabsUiValue: PlatformParameterValue @@ -366,7 +375,8 @@ class TopicLessonsFragmentTest { .check( matches( withContentDescription( - "Complete Chapter 1: What is a Ratio? to unlock this chapter." + "Chapter 2: Order is important is currently locked. Please complete chapter 1: " + + "What is a Ratio? to unlock this chapter." ) ) ) From 67c1d7940ab7aa82f6105443b8d78f81b51430c2 Mon Sep 17 00:00:00 2001 From: Adhiambo Peres <59600948+adhiamboperes@users.noreply.github.com> Date: Fri, 7 Jul 2023 12:41:51 +0300 Subject: [PATCH 04/33] Fix#4881: Build UI for the Android NPS Survey (#4945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation Fixes #4881. This is PR 3 of 6 that adds the UI for the NPS survey as well as completes the [key user flows](https://docs.google.com/document/d/1aIr7cu7V5-9uvhfDgekq-iOXF57hbxEmjiL3mElly0I/edit#heading=h.rkqdyq11md2s). ### Layouts Introduced #### Main Layouts - Survey intro - Survey fragment for displaying the questions - Exit confirmation dialog - Thank you screen #### Technical decisions I used the `BindableAdapter.MultiTypeBuilder` from within the fragment to populate the views. This allowed me to reuse the survey question layout while creating individual layouts for each answer option group. I created a viewmodel hierachy where: - The root fragment viewmodel is responsible for retrieving the correct question text, making the decision to allow navigation to the next question by enabling/disabling the next button based on selected answer availability, and updating the progressbar percentage and progress. - For the answer option layouts, I created a Super-class, `SurveyAnswerItemViewModel` for representing the different types of different layouts that can exist for the recyclerView responsible for displaying these views. - Each question option layout has it's own viewmodel that is responsible for retrieving the answer options to display from the app strings file, notifying that an answer has been provided, and retrieving the selected answer for handling. I used view binding to display all the strings and control navigation. I also created custom views where using regular views proved to be complex to work with. #### Custom Views - SurveyOnboardingBackgroundView - for creating the custom background needed on the intro and outro layouts - SurveyMultipleChoiceOptionView - for binding lists in multiple choice questions - SurveyNpsItemOptionView - for binding the custom layout needed for the NPS score question ### Navigation and Progress I found it difficult to decouple the UI development work from the progress work, since the survey and the questions are generated dynaically so I needed to develop both in tandem. The following changes pertain to the core survey logic: #### SurveyController Creates a survey with a list of questions. #### SurveyProgressController Tracks the non-persisted progress of a survey and is responsible for handling the survey session, managing the commands for navigating through and answer submission. I opted to use a command pattern similar to what is used in the [ExplorationProgressController](https://github.com/oppia/oppia-android/blob/develop/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationProgressController.kt). #### Navigation The survey can be viewed as a series of linear questions, which we will navigate through using methods defined in the SurveyQuestionDeck, modeled after the [StateDeck](https://github.com/oppia/oppia-android/blob/develop/domain/src/main/java/org/oppia/android/domain/state/StateDeck.kt). To start with, we will create an EphemeralSurveyQuestion which represents the question the user is currently viewing. It contains a reference to the next and previous questions if applicable, and a reference to whether it is the last question in the deck. The SurveyQuestionDeck will define the conditions for navigating to the previous and next questions as well as the exceptions thrown in case a navigation action is invalid. A SurveyQuestionGraph class will be created to provide functionality for processing the behavior of a ‘next’ navigation action, which evaluates which question is shown next. ### Survey Proto Changes: - Refactored the order of the enum items in the `UserTypeAnswer` enum so that they result into a properly indexed options list per the mocks. - Modified the `EphemeralSurveyQuestion` to have a `current_question_index` and a `total_question_count` to make it easy to compute the survey progress. - Added answer typecase `free_form_answer` to the `SurveySelectedAnswer` message to hold the value of a free text response. - Added ` SurveyQuestion optional_question = 3;` field to the survey so that we have a clear seperation between the mandatory and optional questions. This also supports the survey generation architecture. ### Bazel Created `BUILD` definitions for all new classes and tests. ### Dagger I changed [approximately 150 test files](https://pbs.twimg.com/media/EAeaeOnU4AcXRQw.jpg) to include `SurveyQuestionModule::class`, though I removed it in a subsequent refactor. When I removed the module and it's usages, it was not a very clean revert because some of the files were not properly formatted before, but they are now -- hence some test files here with only formatting changes. ### Usages, Future Work and Out of Scope The domain layer of the survey is pretty much flexible to support any survey question, including should the current list be reordered or the number of questions changed. For example, if we wanted to show a subsequent survey on the same profile: #### Show One Question Only We would be able to show only one question if we only passed that one question name to the list in the `startSurveySession()` function. We would also need to set `showOptionalQuestion` to false, to disable adding the free-form question to the survey. This is otherwise created by default as `showOptionalQuestion` is always true otherwise. Further work would need to be done in the UI to to update the navigation button behaviour, i.e currently the button will show next instead of submit, and to ensure the submit answer functionality works to exit survey once the one answer has been submitted. #### Show Two or More Questions We could for example want to show only the NPS score question and the feedback question. We would need to pass in the NPS question's name as an input to the `startSurveySession()` function. The feedback question name never needs to be explicitly passed since we create a default (promoter) question and typically update the actual question based on the NPS score. The UI currently supports this scenario. #### Add More Question Types The domain layer would be able to handle any question types, but the UI will need to be updated to display this question by creating a new layout, and viewmodel for it, as well as updating the `SurveyAnswerItemViewModel`'s `ViewType` enum with this new viewType(s). A more longterm solution for a dynamic UI would be to create standardized survey questions and answer options, pretty much like state/exploration interactions. ## Essential Checklist - [x] The PR title and explanation each start with "Fix #bugnum: " (If this PR fixes part of an issue, prefix the title with "Fix part of #bugnum: ...".) - [x] Any changes to [scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets) files have their rationale included in the PR explanation. - [x] The PR follows the [style guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide). - [x] The PR does not contain any unnecessary code changes from Android Studio ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)). - [x] The PR is made from a branch that's **not** called "develop" and is up-to-date with "develop". - [x] The PR is **assigned** to the appropriate reviewers ([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)). ## For UI-specific PRs only If your PR includes UI-related changes, then: - Add screenshots for portrait/landscape for both a tablet & phone of the before & after UI changes - For the screenshots above, include both English and pseudo-localized (RTL) screenshots (see [RTL guide] | LTR | RTL | |---|---| |https://github.com/oppia/oppia-android/assets/59600948/d2411422-aa99-482d-a0e7-c6fd163e85e1| https://github.com/oppia/oppia-android/assets/59600948/329e0672-8d4c-4f43-8c6c-b9036a78980a| |Updated LTR video with restored answers| |https://github.com/oppia/oppia-android/assets/59600948/1a0decff-7dbf-4a6a-a225-5f38f4191fd6| --- app/BUILD.bazel | 14 + app/src/main/AndroidManifest.xml | 5 + .../app/activity/ActivityComponentImpl.kt | 2 + .../SurveyOnboardingBackgroundView.kt | 110 +++ .../app/fragment/FragmentComponentImpl.kt | 8 + .../ExplorationActivityPresenter.kt | 57 +- .../player/state/StateFragmentPresenter.kt | 54 +- .../oppia/android/app/shim/ViewBindingShim.kt | 39 + .../android/app/shim/ViewBindingShimImpl.kt | 47 ++ .../ExitSurveyConfirmationDialogFragment.kt | 75 ++ ...rveyConfirmationDialogFragmentPresenter.kt | 77 ++ .../SelectedAnswerAvailabilityReceiver.kt | 29 + .../android/app/survey/SurveyActivity.kt | 65 ++ .../app/survey/SurveyActivityPresenter.kt | 64 ++ .../android/app/survey/SurveyFragment.kt | 77 ++ .../app/survey/SurveyFragmentPresenter.kt | 335 +++++++++ .../survey/SurveyMultipleChoiceOptionView.kt | 79 +++ .../app/survey/SurveyNpsItemOptionView.kt | 79 +++ .../app/survey/SurveyOutroDialogFragment.kt | 54 ++ .../SurveyOutroDialogFragmentPresenter.kt | 74 ++ .../android/app/survey/SurveyViewModel.kt | 97 +++ .../app/survey/SurveyWelcomeDialogFragment.kt | 100 +++ .../SurveyWelcomeDialogFragmentPresenter.kt | 87 +++ .../FreeFormItemsViewModel.kt | 60 ++ .../MarketFitItemsViewModel.kt | 153 ++++ .../MultipleChoiceOptionContentViewModel.kt | 22 + .../surveyitemviewmodel/NpsItemsViewModel.kt | 99 +++ .../SurveyAnswerItemViewModel.kt | 22 + .../UserTypeItemsViewModel.kt | 153 ++++ .../android/app/view/ViewComponentImpl.kt | 6 + ...nt_color_shared_survey_option_selector.xml | 6 + .../rounded_button_white_background_color.xml | 9 + .../rounded_button_white_outline_color.xml | 9 + ...unded_primary_button_grey_shadow_color.xml | 16 + .../survey_confirmation_dialog_background.xml | 9 + .../survey_edit_text_background_border.xml | 9 + .../survey_next_button_background.xml | 7 + .../survey_nps_radio_button_background.xml | 7 + .../survey_nps_radio_selected_color.xml | 10 + .../drawable/survey_nps_radio_text_color.xml | 5 + .../survey_nps_radio_unselected_color.xml | 10 + .../main/res/drawable/survey_progress_bar.xml | 20 + ...vey_rounded_disabled_button_background.xml | 9 + .../survey_submit_button_background.xml | 7 + app/src/main/res/layout/survey_activity.xml | 16 + .../survey_exit_confirmation_dialog.xml | 39 + app/src/main/res/layout/survey_fragment.xml | 139 ++++ .../res/layout/survey_free_form_layout.xml | 42 ++ .../survey_market_fit_question_layout.xml | 17 + .../layout/survey_multiple_choice_item.xml | 47 ++ app/src/main/res/layout/survey_nps_item.xml | 41 ++ .../res/layout/survey_nps_score_layout.xml | 39 + .../layout/survey_outro_dialog_fragment.xml | 55 ++ .../survey_user_type_question_layout.xml | 17 + .../layout/survey_welcome_dialog_fragment.xml | 67 ++ app/src/main/res/values/color_defs.xml | 6 + app/src/main/res/values/color_palette.xml | 9 + app/src/main/res/values/component_colors.xml | 12 + app/src/main/res/values/dimens.xml | 26 +- app/src/main/res/values/strings.xml | 31 + app/src/main/res/values/styles.xml | 151 ++++ .../ProfileAndDeviceIdActivityTest.kt | 31 +- .../devoptions/ViewEventLogsFragmentTest.kt | 23 +- .../android/app/faq/FaqListActivityTest.kt | 1 - .../ProfilePictureActivityTest.kt | 2 +- .../ProfileProgressActivityTest.kt | 2 +- .../ProfileProgressFragmentTest.kt | 2 +- .../android/app/splash/SplashActivityTest.kt | 26 +- .../android/app/survey/SurveyActivityTest.kt | 224 ++++++ .../android/app/survey/SurveyFragmentTest.kt | 628 +++++++++++++++++ .../android/app/home/HomeActivityLocalTest.kt | 10 +- .../ExplorationActivityLocalTest.kt | 2 +- .../ActivityLanguageLocaleHandlerTest.kt | 5 +- .../app/utility/datetime/DateTimeUtilTest.kt | 7 +- .../PlatformParameterAlphaKenyaModule.kt | 4 +- .../PlatformParameterAlphaModule.kt | 30 + .../PlatformParameterModule.kt | 4 +- .../oppia/android/domain/survey/BUILD.bazel | 43 ++ .../android/domain/survey/SurveyController.kt | 126 ++++ .../domain/survey/SurveyGatingController.kt | 4 +- .../android/domain/survey/SurveyProgress.kt | 112 +++ .../domain/survey/SurveyProgressController.kt | 667 ++++++++++++++++++ .../domain/survey/SurveyQuestionDeck.kt | 94 +++ .../domain/survey/SurveyQuestionGraph.kt | 27 + .../domain/audio/AudioPlayerControllerTest.kt | 2 +- .../ExplorationCheckpointControllerTest.kt | 2 +- .../analytics/LearnerAnalyticsLoggerTest.kt | 31 +- .../oppia/android/domain/survey/BUILD.bazel | 54 ++ .../domain/survey/SurveyControllerTest.kt | 233 ++++++ .../survey/SurveyProgressControllerTest.kt | 495 +++++++++++++ model/src/main/proto/BUILD.bazel | 1 + model/src/main/proto/arguments.proto | 8 + model/src/main/proto/screens.proto | 3 + model/src/main/proto/survey.proto | 48 +- .../assets/kdoc_validity_exemptions.textproto | 15 + scripts/assets/test_file_exemptions.textproto | 23 + .../TestPlatformParameterModule.kt | 4 +- .../util/logging/EventBundleCreator.kt | 1 + .../PlatformParameterConstants.kt | 2 +- 99 files changed, 5945 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/org/oppia/android/app/customview/SurveyOnboardingBackgroundView.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt create mode 100644 app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt create mode 100644 app/src/main/res/color/component_color_shared_survey_option_selector.xml create mode 100644 app/src/main/res/drawable/rounded_button_white_background_color.xml create mode 100644 app/src/main/res/drawable/rounded_button_white_outline_color.xml create mode 100644 app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml create mode 100644 app/src/main/res/drawable/survey_confirmation_dialog_background.xml create mode 100644 app/src/main/res/drawable/survey_edit_text_background_border.xml create mode 100644 app/src/main/res/drawable/survey_next_button_background.xml create mode 100644 app/src/main/res/drawable/survey_nps_radio_button_background.xml create mode 100644 app/src/main/res/drawable/survey_nps_radio_selected_color.xml create mode 100644 app/src/main/res/drawable/survey_nps_radio_text_color.xml create mode 100644 app/src/main/res/drawable/survey_nps_radio_unselected_color.xml create mode 100644 app/src/main/res/drawable/survey_progress_bar.xml create mode 100644 app/src/main/res/drawable/survey_rounded_disabled_button_background.xml create mode 100644 app/src/main/res/drawable/survey_submit_button_background.xml create mode 100644 app/src/main/res/layout/survey_activity.xml create mode 100644 app/src/main/res/layout/survey_exit_confirmation_dialog.xml create mode 100644 app/src/main/res/layout/survey_fragment.xml create mode 100644 app/src/main/res/layout/survey_free_form_layout.xml create mode 100644 app/src/main/res/layout/survey_market_fit_question_layout.xml create mode 100644 app/src/main/res/layout/survey_multiple_choice_item.xml create mode 100644 app/src/main/res/layout/survey_nps_item.xml create mode 100644 app/src/main/res/layout/survey_nps_score_layout.xml create mode 100644 app/src/main/res/layout/survey_outro_dialog_fragment.xml create mode 100644 app/src/main/res/layout/survey_user_type_question_layout.xml create mode 100644 app/src/main/res/layout/survey_welcome_dialog_fragment.xml create mode 100644 app/src/sharedTest/java/org/oppia/android/app/survey/SurveyActivityTest.kt create mode 100644 app/src/sharedTest/java/org/oppia/android/app/survey/SurveyFragmentTest.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/survey/SurveyController.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/survey/SurveyProgress.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/survey/SurveyProgressController.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionDeck.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/survey/SurveyQuestionGraph.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/survey/SurveyControllerTest.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/survey/SurveyProgressControllerTest.kt diff --git a/app/BUILD.bazel b/app/BUILD.bazel index 6dafe8a115d..d5a4bfe97fe 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -155,6 +155,7 @@ LISTENERS = [ "src/main/java/org/oppia/android/app/recyclerview/OnItemDragListener.kt", "src/main/java/org/oppia/android/app/settings/profile/LoadProfileEditDeletionDialogListener.kt", "src/main/java/org/oppia/android/app/settings/profile/RouteToProfileEditListener.kt", + "src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt", "src/main/java/org/oppia/android/app/topic/RouteToRevisionCardListener.kt", "src/main/java/org/oppia/android/app/topic/lessons/ChapterSummarySelector.kt", "src/main/java/org/oppia/android/app/topic/lessons/StorySummarySelector.kt", @@ -235,6 +236,12 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/profileprogress/RecentlyPlayedStorySummaryViewModel.kt", "src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt", "src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryHeaderViewModel.kt", + "src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt", + "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt", + "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt", + "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt", + "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt", + "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt", "src/main/java/org/oppia/android/app/topic/TopicViewModel.kt", "src/main/java/org/oppia/android/app/topic/info/TopicInfoViewModel.kt", "src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt", @@ -342,6 +349,7 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/story/StoryFragmentScroller.kt", "src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryItemViewModel.kt", "src/main/java/org/oppia/android/app/story/StoryViewModel.kt", + "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt", "src/main/java/org/oppia/android/app/testing/BindableAdapterTestDataModel.kt", "src/main/java/org/oppia/android/app/testing/BindableAdapterTestViewModel.kt", "src/main/java/org/oppia/android/app/testing/CircularProgressIndicatorAdaptersTestViewModel.kt", @@ -402,7 +410,10 @@ VIEWS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt", "src/main/java/org/oppia/android/app/customview/PromotedStoryCardView.kt", "src/main/java/org/oppia/android/app/customview/SegmentedCircularProgressView.kt", + "src/main/java/org/oppia/android/app/customview/SurveyOnboardingBackgroundView.kt", "src/main/java/org/oppia/android/app/customview/VerticalDashedLineView.kt", + "src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt", + "src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt", "src/main/java/org/oppia/android/app/utility/ClickableAreasImage.kt", ] @@ -621,6 +632,7 @@ kt_android_library( "//domain/src/main/java/org/oppia/android/domain/audio:cellular_audio_dialog_controller", "//model/src/main/proto:arguments_java_proto_lite", "//model/src/main/proto:question_java_proto_lite", + "//model/src/main/proto:survey_java_proto_lite", "//model/src/main/proto:topic_java_proto_lite", "//third_party:androidx_recyclerview_recyclerview", ], @@ -781,6 +793,8 @@ kt_android_library( "//domain/src/main/java/org/oppia/android/domain/oppialogger:startup_listener", "//domain/src/main/java/org/oppia/android/domain/profile:profile_management_controller", "//domain/src/main/java/org/oppia/android/domain/spotlight:spotlight_state_controller", + "//domain/src/main/java/org/oppia/android/domain/survey:gating_controller", + "//domain/src/main/java/org/oppia/android/domain/survey:survey_controller", "//model/src/main/proto:arguments_java_proto_lite", "//third_party:androidx_databinding_databinding-adapters", "//third_party:androidx_databinding_databinding-common", diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1f1fc369830..bad515549bb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -307,6 +307,11 @@ + (this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) + } +} diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt index 36b83c8244d..d89e5b1297b 100644 --- a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt +++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt @@ -65,6 +65,10 @@ import org.oppia.android.app.shim.IntentFactoryShimModule import org.oppia.android.app.shim.ViewBindingShimModule import org.oppia.android.app.spotlight.SpotlightFragment import org.oppia.android.app.story.StoryFragment +import org.oppia.android.app.survey.ExitSurveyConfirmationDialogFragment +import org.oppia.android.app.survey.SurveyFragment +import org.oppia.android.app.survey.SurveyOutroDialogFragment +import org.oppia.android.app.survey.SurveyWelcomeDialogFragment import org.oppia.android.app.testing.DragDropTestFragment import org.oppia.android.app.testing.ExplorationTestActivityPresenter import org.oppia.android.app.testing.ImageRegionSelectionTestFragment @@ -177,4 +181,8 @@ interface FragmentComponentImpl : FragmentComponent, ViewComponentBuilderInjecto fun inject(walkthroughFinalFragment: WalkthroughFinalFragment) fun inject(walkthroughTopicListFragment: WalkthroughTopicListFragment) fun inject(walkthroughWelcomeFragment: WalkthroughWelcomeFragment) + fun inject(surveyFragment: SurveyFragment) + fun inject(exitSurveyConfirmationDialogFragment: ExitSurveyConfirmationDialogFragment) + fun inject(surveyWelcomeDialogFragment: SurveyWelcomeDialogFragment) + fun inject(surveyOutroDialogFragment: SurveyOutroDialogFragment) } 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 f2476a44c3d..cdef02a2692 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 @@ -19,6 +19,7 @@ import org.oppia.android.app.model.ExplorationActivityParams import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.ReadingTextSize import org.oppia.android.app.model.Spotlight +import org.oppia.android.app.model.SurveyQuestionName import org.oppia.android.app.options.OptionsActivity import org.oppia.android.app.player.stopplaying.ProgressDatabaseFullDialogFragment import org.oppia.android.app.player.stopplaying.UnsavedExplorationDialogFragment @@ -26,6 +27,8 @@ import org.oppia.android.app.spotlight.SpotlightFragment import org.oppia.android.app.spotlight.SpotlightManager import org.oppia.android.app.spotlight.SpotlightShape import org.oppia.android.app.spotlight.SpotlightTarget +import org.oppia.android.app.survey.SurveyWelcomeDialogFragment +import org.oppia.android.app.survey.TAG_SURVEY_WELCOME_DIALOG import org.oppia.android.app.topic.TopicActivity import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.FontScaleConfigurationUtil @@ -33,6 +36,7 @@ import org.oppia.android.app.viewmodel.ViewModelProvider import org.oppia.android.databinding.ExplorationActivityBinding import org.oppia.android.domain.exploration.ExplorationDataController import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.survey.SurveyGatingController import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData @@ -54,7 +58,8 @@ class ExplorationActivityPresenter @Inject constructor( private val fontScaleConfigurationUtil: FontScaleConfigurationUtil, private val translationController: TranslationController, private val oppiaLogger: OppiaLogger, - private val resourceHandler: AppLanguageResourceHandler + private val resourceHandler: AppLanguageResourceHandler, + private val surveyGatingController: SurveyGatingController ) { private lateinit var explorationToolbar: Toolbar private lateinit var explorationToolbarTitle: TextView @@ -279,8 +284,9 @@ class ExplorationActivityPresenter @Inject constructor( oppiaLogger.e("ExplorationActivity", "Failed to stop exploration", it.error) is AsyncResult.Success -> { oppiaLogger.d("ExplorationActivity", "Successfully stopped exploration") - backPressActivitySelector() - (activity as ExplorationActivity).finish() + if (isCompletion) { + maybeShowSurveyDialog(profileId, topicId) + } } } } @@ -304,6 +310,8 @@ class ExplorationActivityPresenter @Inject constructor( * current exploration. */ fun backButtonPressed() { + // check if survey should be shown + maybeShowSurveyDialog(profileId, topicId) // If checkpointing is not enabled, show StopExplorationDialogFragment to exit the exploration, // this is expected to happen if the exploration is marked as completed. if (!isCheckpointingEnabled) { @@ -500,4 +508,47 @@ class ExplorationActivityPresenter @Inject constructor( } } } + + private fun maybeShowSurveyDialog(profileId: ProfileId, topicId: String) { + surveyGatingController.maybeShowSurvey(profileId, topicId).toLiveData() + .observe( + activity, + { gatingResult -> + when (gatingResult) { + is AsyncResult.Pending -> { + oppiaLogger.d("ExplorationActivity", "A gating decision is pending") + } + is AsyncResult.Failure -> { + oppiaLogger.e( + "ExplorationActivity", + "Failed to retrieve gating decision", + gatingResult.error + ) + backPressActivitySelector() + } + is AsyncResult.Success -> { + if (gatingResult.value) { + val dialogFragment = + SurveyWelcomeDialogFragment.newInstance(profileId, topicId, SURVEY_QUESTIONS) + val transaction = activity.supportFragmentManager.beginTransaction() + transaction + .add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG) + .addToBackStack(null) + .commit() + } else { + backPressActivitySelector() + } + } + } + } + ) + } + + companion object { + private val SURVEY_QUESTIONS = listOf( + SurveyQuestionName.USER_TYPE, + SurveyQuestionName.MARKET_FIT, + SurveyQuestionName.NPS + ) + } } 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 304694c7b2e..b0c7687ac28 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 @@ -25,6 +25,7 @@ import org.oppia.android.app.model.EphemeralState import org.oppia.android.app.model.HelpIndex 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.player.audio.AudioButtonListener import org.oppia.android.app.player.audio.AudioFragment @@ -34,6 +35,8 @@ import org.oppia.android.app.player.state.ConfettiConfig.MEDIUM_CONFETTI_BURST import org.oppia.android.app.player.state.ConfettiConfig.MINI_CONFETTI_BURST import org.oppia.android.app.player.state.listener.RouteToHintsAndSolutionListener import org.oppia.android.app.player.stopplaying.StopStatePlayingSessionWithSavedProgressListener +import org.oppia.android.app.survey.SurveyWelcomeDialogFragment +import org.oppia.android.app.survey.TAG_SURVEY_WELCOME_DIALOG import org.oppia.android.app.topic.conceptcard.ConceptCardFragment import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.SplitScreenManager @@ -41,6 +44,7 @@ import org.oppia.android.app.utility.lifecycle.LifecycleSafeTimerFactory import org.oppia.android.databinding.StateFragmentBinding import org.oppia.android.domain.exploration.ExplorationProgressController import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.survey.SurveyGatingController import org.oppia.android.domain.topic.StoryProgressController import org.oppia.android.util.accessibility.AccessibilityService import org.oppia.android.util.data.AsyncResult @@ -76,7 +80,8 @@ class StateFragmentPresenter @Inject constructor( private val oppiaClock: OppiaClock, private val viewModel: StateViewModel, private val accessibilityService: AccessibilityService, - private val resourceHandler: AppLanguageResourceHandler + private val resourceHandler: AppLanguageResourceHandler, + private val surveyGatingController: SurveyGatingController ) { private val routeToHintsAndSolutionListener = activity as RouteToHintsAndSolutionListener @@ -184,8 +189,7 @@ class StateFragmentPresenter @Inject constructor( fun onReturnToTopicButtonClicked() { hideKeyboard() markExplorationCompleted() - (activity as StopStatePlayingSessionWithSavedProgressListener) - .deleteCurrentProgressAndStopSession(isCompletion = true) + maybeShowSurveyDialog(profileId, topicId) } private fun showOrHideAudioByState(state: State) { @@ -524,6 +528,42 @@ class StateFragmentPresenter @Inject constructor( } } + private fun maybeShowSurveyDialog(profileId: ProfileId, topicId: String) { + surveyGatingController.maybeShowSurvey(profileId, topicId).toLiveData() + .observe( + activity, + { gatingResult -> + when (gatingResult) { + is AsyncResult.Pending -> { + oppiaLogger.d("StateFragment", "A gating decision is pending") + } + is AsyncResult.Failure -> { + oppiaLogger.e( + "StateFragment", + "Failed to retrieve gating decision", + gatingResult.error + ) + (activity as StopStatePlayingSessionWithSavedProgressListener) + .deleteCurrentProgressAndStopSession(isCompletion = true) + } + is AsyncResult.Success -> { + if (gatingResult.value) { + val dialogFragment = + SurveyWelcomeDialogFragment.newInstance(profileId, topicId, SURVEY_QUESTIONS) + val transaction = activity.supportFragmentManager.beginTransaction() + transaction + .add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG) + .commitNow() + } else { + (activity as StopStatePlayingSessionWithSavedProgressListener) + .deleteCurrentProgressAndStopSession(isCompletion = true) + } + } + } + } + ) + } + /** * An [Interpolator] when performs a reversed, then regular bounce interpolation using * [BounceInterpolator]. @@ -545,4 +585,12 @@ class StateFragmentPresenter @Inject constructor( } else bounceInterpolator.getInterpolation(input * 2f - 1f) } } + + companion object { + private val SURVEY_QUESTIONS = listOf( + SurveyQuestionName.USER_TYPE, + SurveyQuestionName.MARKET_FIT, + SurveyQuestionName.NPS + ) + } } diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt index cb57334b06d..acc6efcec4a 100644 --- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt +++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt @@ -12,6 +12,7 @@ import org.oppia.android.app.home.promotedlist.PromotedStoryViewModel import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.itemviewmodel.DragDropInteractionContentViewModel import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionContentViewModel +import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel import org.oppia.android.util.parser.html.HtmlParser /** @@ -167,4 +168,42 @@ interface ViewBindingShim { entityId: String, writtenTranslationContext: WrittenTranslationContext ) + + /** + * Handles binding inflation for [SurveyMultipleChoiceOptionView]'s MultipleChoiceOption and + * returns the binding's view. + */ + fun provideMultipleChoiceItemsInflatedView( + inflater: LayoutInflater, + parent: ViewGroup, + attachToParent: Boolean + ): View + + /** + * Handles binding inflation for [SurveyMultipleChoiceOptionView]'s MultipleChoiceOption and + * returns the binding's view model. + */ + fun provideMultipleChoiceOptionViewModel( + view: View, + viewModel: MultipleChoiceOptionContentViewModel + ) + + /** + * Handles binding inflation for [SurveyNpsItemOptionView]'s MultipleChoiceOption and + * returns the binding's view. + */ + fun provideNpsItemsInflatedView( + inflater: LayoutInflater, + parent: ViewGroup, + attachToParent: Boolean + ): View + + /** + * Handles binding inflation for [SurveyNpsItemOptionView]'s MultipleChoiceOption and + * returns the binding's view model. + */ + fun provideNpsItemsViewModel( + view: View, + viewModel: MultipleChoiceOptionContentViewModel + ) } diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt index cd577465e07..69b49ac4eea 100644 --- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt +++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt @@ -14,6 +14,7 @@ import org.oppia.android.app.home.promotedlist.PromotedStoryViewModel import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.itemviewmodel.DragDropInteractionContentViewModel import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionContentViewModel +import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.ComingSoonTopicViewBinding import org.oppia.android.databinding.DragDropInteractionItemsBinding @@ -21,6 +22,8 @@ import org.oppia.android.databinding.DragDropSingleItemBinding import org.oppia.android.databinding.ItemSelectionInteractionItemsBinding import org.oppia.android.databinding.MultipleChoiceInteractionItemsBinding import org.oppia.android.databinding.PromotedStoryCardBinding +import org.oppia.android.databinding.SurveyMultipleChoiceItemBinding +import org.oppia.android.databinding.SurveyNpsItemBinding import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.parser.html.HtmlParser import javax.inject.Inject @@ -148,6 +151,50 @@ class ViewBindingShimImpl @Inject constructor( binding.viewModel = viewModel } + override fun provideMultipleChoiceItemsInflatedView( + inflater: LayoutInflater, + parent: ViewGroup, + attachToParent: Boolean + ): View { + return SurveyMultipleChoiceItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ).root + } + + override fun provideMultipleChoiceOptionViewModel( + view: View, + viewModel: MultipleChoiceOptionContentViewModel + ) { + val binding = + DataBindingUtil.findBinding(view)!! + binding.optionContent = viewModel.optionContent + binding.viewModel = viewModel + } + + override fun provideNpsItemsInflatedView( + inflater: LayoutInflater, + parent: ViewGroup, + attachToParent: Boolean + ): View { + return SurveyNpsItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ).root + } + + override fun provideNpsItemsViewModel( + view: View, + viewModel: MultipleChoiceOptionContentViewModel + ) { + val binding = + DataBindingUtil.findBinding(view)!! + binding.scoreContent = viewModel.optionContent + binding.viewModel = viewModel + } + override fun provideDragDropSortInteractionInflatedView( inflater: LayoutInflater, parent: ViewGroup, diff --git a/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt new file mode 100644 index 00000000000..eef21525cba --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt @@ -0,0 +1,75 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment +import org.oppia.android.app.model.ProfileId +import org.oppia.android.util.extensions.getProto +import org.oppia.android.util.extensions.putProto +import javax.inject.Inject + +/** Fragment that displays a dialog for survey exit confirmation. */ +class ExitSurveyConfirmationDialogFragment : InjectableDialogFragment() { + @Inject + lateinit var exitSurveyConfirmationDialogFragmentPresenter: + ExitSurveyConfirmationDialogFragmentPresenter + + companion object { + internal const val PROFILE_ID_KEY = "ExitSurveyConfirmationDialogFragment.profile_id" + + /** + * Creates a new instance of a DialogFragment to display an exit confirmation in a survey. + * + * @param profileId the ID of the profile viewing the survey + * @return [ExitSurveyConfirmationDialogFragment]: DialogFragment + */ + fun newInstance( + profileId: ProfileId + ): ExitSurveyConfirmationDialogFragment { + return ExitSurveyConfirmationDialogFragment().apply { + arguments = Bundle().apply { + putProto(PROFILE_ID_KEY, profileId) + } + } + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.ExitSurveyConfirmationDialogStyle) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val args = + checkNotNull( + arguments + ) { "Expected arguments to be passed to ExitSurveyConfirmationDialogFragment" } + + val profileId = args.getProto(PROFILE_ID_KEY, ProfileId.getDefaultInstance()) + + return exitSurveyConfirmationDialogFragmentPresenter.handleCreateView( + inflater, + container, + profileId + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.setWindowAnimations(R.style.ExitSurveyConfirmationDialogStyle) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt new file mode 100644 index 00000000000..13dca266290 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt @@ -0,0 +1,77 @@ +package org.oppia.android.app.survey + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.model.ProfileId +import org.oppia.android.databinding.SurveyExitConfirmationDialogBinding +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.survey.SurveyController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import javax.inject.Inject + +const val TAG_EXIT_SURVEY_CONFIRMATION_DIALOG = "EXIT_SURVEY_CONFIRMATION_DIALOG" + +/** Presenter for [ExitSurveyConfirmationDialogFragment], sets up bindings from ViewModel. */ +@FragmentScope +class ExitSurveyConfirmationDialogFragmentPresenter @Inject constructor( + private val fragment: Fragment, + private val activity: AppCompatActivity, + private val surveyController: SurveyController, + private val oppiaLogger: OppiaLogger +) { + + /** Sets up data binding. */ + fun handleCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + profileId: ProfileId + ): View { + val binding = + SurveyExitConfirmationDialogBinding.inflate(inflater, container, /* attachToRoot= */ false) + + binding.lifecycleOwner = fragment + + binding.continueSurveyButton.setOnClickListener { + fragment.parentFragmentManager.beginTransaction() + .remove(fragment) + .commitNow() + } + + binding.exitSurveyButton.setOnClickListener { + endSurveyWithCallback { closeSurveyDialogAndActivity() } + } + + return binding.root + } + + private fun closeSurveyDialogAndActivity() { + activity.finish() + fragment.parentFragmentManager.beginTransaction() + .remove(fragment) + .commitNow() + } + + private fun endSurveyWithCallback(callback: () -> Unit) { + surveyController.stopSurveySession().toLiveData().observe( + activity, + { + when (it) { + is AsyncResult.Pending -> oppiaLogger.d("SurveyActivity", "Stopping survey session") + is AsyncResult.Failure -> { + oppiaLogger.d("SurveyActivity", "Failed to stop the survey session") + activity.finish() // Can't recover from the session failing to stop. + } + is AsyncResult.Success -> { + oppiaLogger.d("SurveyActivity", "Stopped the survey session") + callback() + } + } + } + ) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt b/app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt new file mode 100644 index 00000000000..d0d1c16b079 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt @@ -0,0 +1,29 @@ +package org.oppia.android.app.survey + +import org.oppia.android.app.model.SurveySelectedAnswer + +/** A handler for receiving any change in answer availability to update the 'next' button. */ +interface SelectedAnswerAvailabilityReceiver { + /** Called when the input answer availability changes. */ + fun onPendingAnswerAvailabilityCheck(inputAnswerAvailable: Boolean) +} + +/** A callback that will be called when a user submits an answer. */ +interface SelectedAnswerHandler { + /** Return the current selected answer that is ready for submission. */ + fun getMultipleChoiceAnswer(selectedAnswer: SurveySelectedAnswer) + + /** Return the current text answer that is ready for submission. */ + fun getFreeFormAnswer(answer: SurveySelectedAnswer) +} + +/** A handler for restoring the previous saved answer for a question on back/forward navigation. */ +interface PreviousAnswerHandler { + /** Called when an ephemeral question is loaded to retrieve the previously saved answer. */ + fun getPreviousAnswer(): SurveySelectedAnswer? { + return null + } + + /** Called after a previously saved answer is retrieved to update the UI. */ + fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt new file mode 100644 index 00000000000..0a486a2959d --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt @@ -0,0 +1,65 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.ScreenName +import org.oppia.android.app.model.SurveyActivityParams +import org.oppia.android.util.extensions.getProtoExtra +import org.oppia.android.util.extensions.putProtoExtra +import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName +import javax.inject.Inject + +/** The activity for showing a survey. */ +class SurveyActivity : InjectableAutoLocalizedAppCompatActivity() { + @Inject lateinit var surveyActivityPresenter: SurveyActivityPresenter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (activityComponent as ActivityComponentImpl).inject(this) + + val params = intent.extractParams() + surveyActivityPresenter.handleOnCreate( + this, + params.profileId, + params.topicId + ) + } + + companion object { + private const val PARAMS_KEY = "SurveyActivity.params" + + /** + * A convenience function for creating a new [SurveyActivity] intent by prefilling common + * params needed by the activity. + */ + fun createSurveyActivityIntent( + context: Context, + profileId: ProfileId, + topicId: String + ): Intent { + val params = SurveyActivityParams.newBuilder().apply { + this.profileId = profileId + this.topicId = topicId + }.build() + return createSurveyActivityIntent(context, params) + } + + /** Returns a new [Intent] open a [SurveyActivity] with the specified [params]. */ + fun createSurveyActivityIntent( + context: Context, + params: SurveyActivityParams + ): Intent { + return Intent(context, SurveyActivity::class.java).apply { + putProtoExtra(PARAMS_KEY, params) + decorateWithScreenName(ScreenName.SURVEY_ACTIVITY) + } + } + + private fun Intent.extractParams() = + getProtoExtra(PARAMS_KEY, SurveyActivityParams.getDefaultInstance()) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt new file mode 100644 index 00000000000..eea59246707 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt @@ -0,0 +1,64 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import org.oppia.android.R +import org.oppia.android.app.activity.ActivityScope +import org.oppia.android.app.model.ProfileId +import org.oppia.android.databinding.SurveyActivityBinding +import org.oppia.android.domain.oppialogger.OppiaLogger +import javax.inject.Inject + +private const val TAG_SURVEY_FRAGMENT = "TAG_SURVEY_FRAGMENT" + +const val PROFILE_ID_ARGUMENT_KEY = "profile_id" +const val TOPIC_ID_ARGUMENT_KEY = "topic_id" + +/** The Presenter for [SurveyActivity]. */ +@ActivityScope +class SurveyActivityPresenter @Inject constructor( + private val activity: AppCompatActivity, + private val oppiaLogger: OppiaLogger +) { + private lateinit var profileId: ProfileId + private lateinit var topicId: String + private lateinit var context: Context + + private lateinit var binding: SurveyActivityBinding + + fun handleOnCreate( + context: Context, + profileId: ProfileId, + topicId: String + ) { + binding = DataBindingUtil.setContentView(activity, R.layout.survey_activity) + binding.apply { + lifecycleOwner = activity + } + + this.profileId = profileId + this.topicId = topicId + this.context = context + + if (getSurveyFragment() == null) { + val surveyFragment = SurveyFragment() + val args = Bundle() + args.putInt(PROFILE_ID_ARGUMENT_KEY, profileId.internalId) + args.putString(TOPIC_ID_ARGUMENT_KEY, topicId) + + surveyFragment.arguments = args + activity.supportFragmentManager.beginTransaction().add( + R.id.survey_fragment_placeholder, + surveyFragment, TAG_SURVEY_FRAGMENT + ).commitNow() + } + } + + private fun getSurveyFragment(): SurveyFragment? { + return activity.supportFragmentManager.findFragmentByTag( + TAG_SURVEY_FRAGMENT + ) as? SurveyFragment + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt new file mode 100644 index 00000000000..aafeea3a082 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt @@ -0,0 +1,77 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableFragment +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.util.extensions.getStringFromBundle +import javax.inject.Inject + +/** Fragment that represents the current state of a survey. */ +class SurveyFragment : + InjectableFragment(), + SelectedAnswerAvailabilityReceiver, + SelectedAnswerHandler { + + companion object { + /** + * Creates a new instance of a SurveyFragment. + * + * @param internalProfileId used by SurveyFragment to record the survey action taken by user + * @param topicId used by SurveyFragment for logging purposes + * @return a new instance of [SurveyFragment] + */ + fun newInstance( + internalProfileId: Int, + topicId: String + ): SurveyFragment { + val surveyFragment = SurveyFragment() + val args = Bundle() + args.putInt(PROFILE_ID_ARGUMENT_KEY, internalProfileId) + args.putString(TOPIC_ID_ARGUMENT_KEY, topicId) + surveyFragment.arguments = args + return surveyFragment + } + } + + @Inject + lateinit var surveyFragmentPresenter: SurveyFragmentPresenter + + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val internalProfileId = arguments!!.getInt(PROFILE_ID_ARGUMENT_KEY, -1) + val topicId = arguments!!.getStringFromBundle(TOPIC_ID_ARGUMENT_KEY)!! + + return surveyFragmentPresenter.handleCreateView( + inflater, + container, + internalProfileId, + topicId, + this + ) + } + + override fun onPendingAnswerAvailabilityCheck(inputAnswerAvailable: Boolean) { + surveyFragmentPresenter.updateNextButton(inputAnswerAvailable) + } + + override fun getMultipleChoiceAnswer(selectedAnswer: SurveySelectedAnswer) { + surveyFragmentPresenter.getPendingAnswer(selectedAnswer) + } + + override fun getFreeFormAnswer(answer: SurveySelectedAnswer) { + surveyFragmentPresenter.submitFreeFormAnswer(answer) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt new file mode 100644 index 00000000000..b88148c7eae --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt @@ -0,0 +1,335 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.Toolbar +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexboxLayoutManager +import com.google.android.flexbox.JustifyContent +import org.oppia.android.app.model.EphemeralSurveyQuestion +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.app.recyclerview.BindableAdapter +import org.oppia.android.app.survey.surveyitemviewmodel.FreeFormItemsViewModel +import org.oppia.android.app.survey.surveyitemviewmodel.MarketFitItemsViewModel +import org.oppia.android.app.survey.surveyitemviewmodel.NpsItemsViewModel +import org.oppia.android.app.survey.surveyitemviewmodel.SurveyAnswerItemViewModel +import org.oppia.android.app.survey.surveyitemviewmodel.UserTypeItemsViewModel +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.databinding.SurveyFragmentBinding +import org.oppia.android.databinding.SurveyFreeFormLayoutBinding +import org.oppia.android.databinding.SurveyMarketFitQuestionLayoutBinding +import org.oppia.android.databinding.SurveyNpsScoreLayoutBinding +import org.oppia.android.databinding.SurveyUserTypeQuestionLayoutBinding +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.survey.SurveyProgressController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import javax.inject.Inject + +/** The presenter for [SurveyFragment]. */ +class SurveyFragmentPresenter @Inject constructor( + private val activity: AppCompatActivity, + private val fragment: Fragment, + private val oppiaLogger: OppiaLogger, + private val surveyProgressController: SurveyProgressController, + private val surveyViewModel: SurveyViewModel, + private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory, + private val resourceHandler: AppLanguageResourceHandler +) { + private val ephemeralQuestionLiveData: LiveData> by lazy { + surveyProgressController.getCurrentQuestion().toLiveData() + } + + private lateinit var profileId: ProfileId + private lateinit var topicId: String + private lateinit var binding: SurveyFragmentBinding + private lateinit var surveyToolbar: Toolbar + private lateinit var answerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver + private lateinit var answerHandler: SelectedAnswerHandler + private lateinit var questionSelectedAnswer: SurveySelectedAnswer + + /** Sets up data binding. */ + fun handleCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + internalProfileId: Int, + topicId: String, + fragment: SurveyFragment + ): View? { + profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build() + this.topicId = topicId + this.answerAvailabilityReceiver = fragment + this.answerHandler = fragment + + binding = SurveyFragmentBinding.inflate( + inflater, + container, + /* attachToRoot= */ false + ) + binding.apply { + lifecycleOwner = fragment + viewModel = surveyViewModel + } + + surveyToolbar = binding.surveyToolbar + activity.setSupportActionBar(surveyToolbar) + surveyToolbar.setNavigationOnClickListener { + val dialogFragment = ExitSurveyConfirmationDialogFragment.newInstance(profileId) + dialogFragment.showNow(fragment.childFragmentManager, TAG_EXIT_SURVEY_CONFIRMATION_DIALOG) + } + + binding.surveyAnswersRecyclerView.apply { + adapter = createRecyclerViewAdapter() + } + + binding.surveyNextButton.setOnClickListener { + if (::questionSelectedAnswer.isInitialized) { + surveyProgressController.submitAnswer(questionSelectedAnswer) + } + } + + binding.surveyPreviousButton.setOnClickListener { + surveyProgressController.moveToPreviousQuestion() + } + + subscribeToCurrentQuestion() + + return binding.root + } + + private fun createRecyclerViewAdapter(): BindableAdapter { + return multiTypeBuilderFactory + .create { viewModel -> + when (viewModel) { + is MarketFitItemsViewModel -> { + SurveyAnswerItemViewModel.ViewType.MARKET_FIT_OPTIONS + } + is UserTypeItemsViewModel -> { + SurveyAnswerItemViewModel.ViewType.USER_TYPE_OPTIONS + } + is NpsItemsViewModel -> { + SurveyAnswerItemViewModel.ViewType.NPS_OPTIONS + } + is FreeFormItemsViewModel -> { + SurveyAnswerItemViewModel.ViewType.FREE_FORM_ANSWER + } + else -> { + throw IllegalStateException("Invalid ViewType") + } + } + } + .registerViewDataBinder( + viewType = SurveyAnswerItemViewModel.ViewType.USER_TYPE_OPTIONS, + inflateDataBinding = SurveyUserTypeQuestionLayoutBinding::inflate, + setViewModel = SurveyUserTypeQuestionLayoutBinding::setViewModel, + transformViewModel = { it as UserTypeItemsViewModel } + ) + .registerViewDataBinder( + viewType = SurveyAnswerItemViewModel.ViewType.MARKET_FIT_OPTIONS, + inflateDataBinding = SurveyMarketFitQuestionLayoutBinding::inflate, + setViewModel = SurveyMarketFitQuestionLayoutBinding::setViewModel, + transformViewModel = { it as MarketFitItemsViewModel } + ) + .registerViewBinder( + viewType = SurveyAnswerItemViewModel.ViewType.NPS_OPTIONS, + inflateView = { parent -> + SurveyNpsScoreLayoutBinding.inflate( + LayoutInflater.from(parent.context), + parent, + /* attachToParent= */ false + ).root + }, + bindView = { view, viewModel -> + val binding = DataBindingUtil.findBinding(view)!! + val npsViewModel = viewModel as NpsItemsViewModel + binding.viewModel = npsViewModel + + val flexLayoutManager = FlexboxLayoutManager(activity) + flexLayoutManager.flexDirection = FlexDirection.ROW + flexLayoutManager.justifyContent = JustifyContent.CENTER + + binding.surveyNpsButtonsContainer.layoutManager = flexLayoutManager + } + ) + .registerViewDataBinder( + viewType = SurveyAnswerItemViewModel.ViewType.FREE_FORM_ANSWER, + inflateDataBinding = SurveyFreeFormLayoutBinding::inflate, + setViewModel = SurveyFreeFormLayoutBinding::setViewModel, + transformViewModel = { it as FreeFormItemsViewModel } + ) + .build() + } + + private fun subscribeToCurrentQuestion() { + ephemeralQuestionLiveData.observe( + fragment, + { + processEphemeralQuestionResult(it) + } + ) + } + + private fun processEphemeralQuestionResult(result: AsyncResult) { + when (result) { + is AsyncResult.Failure -> { + oppiaLogger.e( + "SurveyFragment", "Failed to retrieve ephemeral question", result.error + ) + } + is AsyncResult.Pending -> {} // Display nothing until a valid result is available. + is AsyncResult.Success -> processEphemeralQuestion(result.value) + } + } + + private fun processEphemeralQuestion(ephemeralQuestion: EphemeralSurveyQuestion) { + val questionName = ephemeralQuestion.question.questionName + surveyViewModel.itemList.clear() + when (questionName) { + SurveyQuestionName.USER_TYPE -> surveyViewModel.itemList.add( + UserTypeItemsViewModel( + resourceHandler, + answerAvailabilityReceiver, + answerHandler + ) + ) + SurveyQuestionName.MARKET_FIT -> surveyViewModel.itemList.add( + MarketFitItemsViewModel( + resourceHandler, + answerAvailabilityReceiver, + answerHandler + ) + ) + SurveyQuestionName.NPS -> surveyViewModel.itemList.add( + NpsItemsViewModel( + answerAvailabilityReceiver, + answerHandler + ) + ) + SurveyQuestionName.PROMOTER_FEEDBACK -> surveyViewModel.itemList.add( + FreeFormItemsViewModel( + answerAvailabilityReceiver, + questionName, + answerHandler + ) + ) + SurveyQuestionName.PASSIVE_FEEDBACK -> surveyViewModel.itemList.add( + FreeFormItemsViewModel( + answerAvailabilityReceiver, + questionName, + answerHandler + ) + ) + SurveyQuestionName.DETRACTOR_FEEDBACK -> surveyViewModel.itemList.add( + FreeFormItemsViewModel( + answerAvailabilityReceiver, + questionName, + answerHandler + ) + ) + else -> {} + } + updateProgress(ephemeralQuestion.currentQuestionIndex, ephemeralQuestion.totalQuestionCount) + updateQuestionText(questionName) + + if (ephemeralQuestion.selectedAnswer != SurveySelectedAnswer.getDefaultInstance()) { + surveyViewModel.retrievePreviousAnswer( + ephemeralQuestion.selectedAnswer, + ::getPreviousAnswerHandler + ) + } + } + + private fun getPreviousAnswerHandler( + itemList: List + ): PreviousAnswerHandler? { + return itemList.findLast { it is PreviousAnswerHandler } as? PreviousAnswerHandler + } + + private fun updateProgress(currentQuestionIndex: Int, questionCount: Int) { + surveyViewModel.updateQuestionProgress( + progressPercentage = (((currentQuestionIndex + 1) / questionCount.toDouble()) * 100).toInt() + ) + toggleNavigationButtonVisibility(currentQuestionIndex, questionCount) + } + + private fun updateQuestionText(questionName: SurveyQuestionName) { + surveyViewModel.updateQuestionText(questionName) + } + + /** + * Updates whether the 'next' button should be active based on whether an answer to the current + * question has been provided. + */ + fun updateNextButton(inputAnswerAvailable: Boolean) { + surveyViewModel.setCanMoveToNextQuestion(inputAnswerAvailable) + } + + /** Retrieves the answer that was selected by the user for a question. */ + fun getPendingAnswer(answer: SurveySelectedAnswer) { + this.questionSelectedAnswer = answer + } + + /** + * Retrieves and submits the free text answer that was provided by the user, then navigates to the + * final screen. + */ + fun submitFreeFormAnswer(answer: SurveySelectedAnswer) { + hideKeyboard() + surveyProgressController.submitAnswer(answer).toLiveData().observe( + fragment, + { result -> + when (result) { + is AsyncResult.Failure -> { + oppiaLogger.e( + "SurveyFragment", "Failed to submit free form answer", result.error + ) + } + is AsyncResult.Pending -> {} // Do nothing until a valid result is available. + is AsyncResult.Success -> { + val dialogFragment = SurveyOutroDialogFragment.newInstance() + val transaction = activity.supportFragmentManager.beginTransaction() + transaction + .add(dialogFragment, TAG_SURVEY_OUTRO_DIALOG) + .commit() + activity.supportFragmentManager.executePendingTransactions() + } + } + } + ) + } + + private fun toggleNavigationButtonVisibility(questionIndex: Int, questionCount: Int) { + when (questionIndex) { + 0 -> { + binding.surveyNextButton.visibility = View.VISIBLE + binding.surveyPreviousButton.visibility = View.GONE + } + (questionCount - 1) -> { + binding.surveyNextButton.visibility = View.GONE + binding.surveyPreviousButton.visibility = View.VISIBLE + } + else -> { + binding.surveyNextButton.visibility = View.VISIBLE + binding.surveyPreviousButton.visibility = View.VISIBLE + } + } + } + + private fun hideKeyboard() { + val inputManager: InputMethodManager = + activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputManager.hideSoftInputFromWindow( + fragment.view!!.windowToken, + InputMethodManager.SHOW_FORCED + ) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt new file mode 100644 index 00000000000..9823860f2a6 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt @@ -0,0 +1,79 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.databinding.ObservableList +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView +import org.oppia.android.app.recyclerview.BindableAdapter +import org.oppia.android.app.shim.ViewBindingShim +import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel +import org.oppia.android.app.view.ViewComponentFactory +import org.oppia.android.app.view.ViewComponentImpl +import javax.inject.Inject + +/** + * A custom [RecyclerView] for displaying a variable list of items that may be selected by a user as + * part of the multiple choice option selection. + */ +class SurveyMultipleChoiceOptionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RecyclerView(context, attrs, defStyleAttr) { + @Inject + lateinit var bindingInterface: ViewBindingShim + + @Inject + lateinit var singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory + private lateinit var dataList: ObservableList + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) + maybeInitializeAdapter() + } + + /** + * Sets the view's RecyclerView [MultipleChoiceOptionContentViewModel] data list. + * + * Note that this needs to be used instead of the generic RecyclerView 'data' binding adapter + * since this one takes into account initialization order with other binding properties. + */ + fun setSelectionData(dataList: ObservableList) { + this.dataList = dataList + maybeInitializeAdapter() + } + + private fun maybeInitializeAdapter() { + if (::singleTypeBuilderFactory.isInitialized && + ::dataList.isInitialized + ) { + adapter = createAdapter().also { it.setData(dataList) } + } + } + + private fun createAdapter(): BindableAdapter { + return singleTypeBuilderFactory.create() + .registerViewBinder( + inflateView = { parent -> + bindingInterface.provideMultipleChoiceItemsInflatedView( + LayoutInflater.from(parent.context), + parent, + /* attachToParent= */ false + ) + }, + bindView = { view, viewModel -> + bindingInterface.provideMultipleChoiceOptionViewModel( + view, + viewModel + ) + } + ) + .build() + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt new file mode 100644 index 00000000000..85625f9a071 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt @@ -0,0 +1,79 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.databinding.ObservableList +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView +import org.oppia.android.app.recyclerview.BindableAdapter +import org.oppia.android.app.shim.ViewBindingShim +import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel +import org.oppia.android.app.view.ViewComponentFactory +import org.oppia.android.app.view.ViewComponentImpl +import javax.inject.Inject + +/** + * A custom [RecyclerView] for displaying a list of radio buttons that may be selected by a user as + * a score for the nps score survey question. + */ +class SurveyNpsItemOptionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RecyclerView(context, attrs, defStyleAttr) { + @Inject + lateinit var bindingInterface: ViewBindingShim + + @Inject + lateinit var singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory + private lateinit var dataList: ObservableList + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) + maybeInitializeAdapter() + } + + /** + * Sets the view's RecyclerView [MultipleChoiceOptionContentViewModel] data list. + * + * Note that this needs to be used instead of the generic RecyclerView 'data' binding adapter + * since this one takes into account initialization order with other binding properties. + */ + fun setSelectionData(dataList: ObservableList) { + this.dataList = dataList + maybeInitializeAdapter() + } + + private fun maybeInitializeAdapter() { + if (::singleTypeBuilderFactory.isInitialized && + ::dataList.isInitialized + ) { + adapter = createAdapter().also { it.setData(dataList) } + } + } + + private fun createAdapter(): BindableAdapter { + return singleTypeBuilderFactory.create() + .registerViewBinder( + inflateView = { parent -> + bindingInterface.provideNpsItemsInflatedView( + LayoutInflater.from(parent.context), + parent, + /* attachToParent= */ false + ) + }, + bindView = { view, viewModel -> + bindingInterface.provideNpsItemsViewModel( + view, + viewModel + ) + } + ) + .build() + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt new file mode 100644 index 00000000000..b988415c547 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt @@ -0,0 +1,54 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment +import javax.inject.Inject + +/** Fragment that displays a fullscreen dialog for survey on-boarding. */ +class SurveyOutroDialogFragment : InjectableDialogFragment() { + @Inject + lateinit var surveyOutroDialogFragmentPresenter: SurveyOutroDialogFragmentPresenter + + companion object { + /** + * Creates a new instance of a DialogFragment to display the survey thank you message. + * + * @return [SurveyOutroDialogFragment]: DialogFragment + */ + fun newInstance(): SurveyOutroDialogFragment { + return SurveyOutroDialogFragment() + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.SurveyOnboardingDialogStyle) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return surveyOutroDialogFragmentPresenter.handleCreateView( + inflater, + container + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.setWindowAnimations(R.style.SurveyOnboardingDialogStyle) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt new file mode 100644 index 00000000000..6ad91d5dde5 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt @@ -0,0 +1,74 @@ +package org.oppia.android.app.survey + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.databinding.SurveyOutroDialogFragmentBinding +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.survey.SurveyController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import javax.inject.Inject + +const val TAG_SURVEY_OUTRO_DIALOG = "SURVEY_OUTRO_DIALOG" + +/** Presenter for [SurveyWelcomeDialogFragment], sets up bindings. */ +@FragmentScope +class SurveyOutroDialogFragmentPresenter @Inject constructor( + private val activity: AppCompatActivity, + private val fragment: Fragment, + private val resourceHandler: AppLanguageResourceHandler, + private val surveyController: SurveyController, + private val oppiaLogger: OppiaLogger +) { + /** Sets up data binding. */ + fun handleCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + ): View { + val binding = + SurveyOutroDialogFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) + + binding.lifecycleOwner = fragment + + val appName = resourceHandler.getStringInLocale(R.string.app_name) + binding.surveyOnboardingText.text = resourceHandler.getStringInLocaleWithWrapping( + R.string.survey_thank_you_message_text, appName + ) + + binding.finishSurveyButton.setOnClickListener { + endSurveyWithCallback { closeSurveyDialogAndActivity() } + } + + return binding.root + } + + private fun closeSurveyDialogAndActivity() { + activity.finish() + activity.supportFragmentManager.beginTransaction().remove(fragment).commitNow() + } + + private fun endSurveyWithCallback(callback: () -> Unit) { + surveyController.stopSurveySession().toLiveData().observe( + activity, + { + when (it) { + is AsyncResult.Pending -> oppiaLogger.d("SurveyActivity", "Stopping survey session") + is AsyncResult.Failure -> { + oppiaLogger.d("SurveyActivity", "Failed to stop the survey session") + activity.finish() // Can't recover from the session failing to stop. + } + is AsyncResult.Success -> { + oppiaLogger.d("SurveyActivity", "Stopped the survey session") + callback() + } + } + } + ) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt new file mode 100644 index 00000000000..0979177affe --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt @@ -0,0 +1,97 @@ +package org.oppia.android.app.survey + +import androidx.databinding.ObservableField +import androidx.databinding.ObservableList +import org.oppia.android.R +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.app.survey.surveyitemviewmodel.SurveyAnswerItemViewModel +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.viewmodel.ObservableArrayList +import org.oppia.android.app.viewmodel.ObservableViewModel +import javax.inject.Inject + +class SurveyViewModel @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler +) : ObservableViewModel() { + val itemList: ObservableList = ObservableArrayList() + val itemIndex = ObservableField() + private val canMoveToNextQuestion = ObservableField(false) + + val progressPercentage = ObservableField(0) + + val questionProgressText: ObservableField = + ObservableField("$DEFAULT_QUESTION_PROGRESS%") + + val questionText: ObservableField = + ObservableField(DEFAULT_QUESTION) + + fun updateQuestionProgress( + progressPercentage: Int + ) { + this.progressPercentage.set(progressPercentage) + questionProgressText.set("$progressPercentage%") + } + + fun updateQuestionText(questionName: SurveyQuestionName) { + questionText.set(getQuestionText(questionName)) + setCanMoveToNextQuestion(false) + } + + fun setCanMoveToNextQuestion(canMoveToNext: Boolean) = + this.canMoveToNextQuestion.set(canMoveToNext) + + fun getCanMoveToNextQuestion(): ObservableField = canMoveToNextQuestion + + fun retrievePreviousAnswer( + previousAnswer: SurveySelectedAnswer, + retrieveAnswerHandler: (List) -> PreviousAnswerHandler? + ) { + restorePreviousAnswer( + previousAnswer, + retrieveAnswerHandler( + itemList + ) + ) + } + + private fun restorePreviousAnswer( + previousAnswer: SurveySelectedAnswer, + answerHandler: PreviousAnswerHandler? + ) { + answerHandler?.restorePreviousAnswer(previousAnswer) + } + + private fun getQuestionText( + questionName: SurveyQuestionName + ): String { + val appName = resourceHandler.getStringInLocale(R.string.app_name) + return when (questionName) { + SurveyQuestionName.USER_TYPE -> resourceHandler.getStringInLocale( + R.string.user_type_question + ) + SurveyQuestionName.MARKET_FIT -> resourceHandler.getStringInLocaleWithWrapping( + R.string.market_fit_question, appName + ) + SurveyQuestionName.NPS -> resourceHandler.getStringInLocaleWithWrapping( + R.string.nps_score_question, appName + ) + SurveyQuestionName.PROMOTER_FEEDBACK -> resourceHandler.getStringInLocaleWithWrapping( + R.string.nps_promoter_feedback_question, appName + ) + SurveyQuestionName.PASSIVE_FEEDBACK -> resourceHandler.getStringInLocaleWithWrapping( + R.string.nps_passive_feedback_question + ) + SurveyQuestionName.DETRACTOR_FEEDBACK -> resourceHandler.getStringInLocaleWithWrapping( + R.string.nps_detractor_feedback_question + ) + SurveyQuestionName.UNRECOGNIZED, SurveyQuestionName.QUESTION_NAME_UNSPECIFIED -> + DEFAULT_QUESTION + } + } + + private companion object { + private const val DEFAULT_QUESTION_PROGRESS = 25 + private const val DEFAULT_QUESTION = "some_question" + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt new file mode 100644 index 00000000000..528f3b7d9b4 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt @@ -0,0 +1,100 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.util.extensions.getProto +import org.oppia.android.util.extensions.getStringFromBundle +import org.oppia.android.util.extensions.putProto +import javax.inject.Inject + +/** Fragment that displays a fullscreen dialog for survey on-boarding. */ +class SurveyWelcomeDialogFragment : InjectableDialogFragment() { + @Inject + lateinit var surveyWelcomeDialogFragmentPresenter: SurveyWelcomeDialogFragmentPresenter + + companion object { + internal const val PROFILE_ID_KEY = "SurveyWelcomeDialogFragment.profile_id" + internal const val TOPIC_ID_KEY = "SurveyWelcomeDialogFragment.topic_id" + internal const val MANDATORY_QUESTION_NAMES_KEY = "SurveyWelcomeDialogFragment.question_names" + + /** + * Creates a new instance of a DialogFragment to display the survey on-boarding message. + * + * @param profileId the ID of the profile viewing the survey prompt + * @return [SurveyWelcomeDialogFragment]: DialogFragment + */ + fun newInstance( + profileId: ProfileId, + topicId: String, + mandatoryQuestionNames: List, + ): SurveyWelcomeDialogFragment { + return SurveyWelcomeDialogFragment().apply { + arguments = Bundle().apply { + putProto(PROFILE_ID_KEY, profileId) + putString(TOPIC_ID_KEY, topicId) + putQuestions(MANDATORY_QUESTION_NAMES_KEY, extractQuestions(mandatoryQuestionNames)) + } + } + } + } + + private fun Bundle.putQuestions(name: String, nameList: IntArray) { + putSerializable(name, nameList) + } + + private fun extractQuestions(questionNames: List): IntArray { + return questionNames.map { questionName -> questionName.number }.toIntArray() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.SurveyOnboardingDialogStyle) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val args = + checkNotNull( + arguments + ) { "Expected arguments to be passed to SurveyWelcomeDialogFragment" } + + val profileId = args.getProto(PROFILE_ID_KEY, ProfileId.getDefaultInstance()) + val topicId = args.getStringFromBundle(TOPIC_ID_KEY)!! + val surveyQuestions = args.getQuestions() + + return surveyWelcomeDialogFragmentPresenter.handleCreateView( + inflater, + container, + profileId, + topicId, + surveyQuestions + ) + } + + private fun Bundle.getQuestions(): List { + val questionArgs = getIntArray(MANDATORY_QUESTION_NAMES_KEY) + return questionArgs?.map { number -> SurveyQuestionName.forNumber(number) } + ?: listOf() + } + + override fun onStart() { + super.onStart() + dialog?.window?.setWindowAnimations(R.style.SurveyOnboardingDialogStyle) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt new file mode 100644 index 00000000000..92212cd6dab --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt @@ -0,0 +1,87 @@ +package org.oppia.android.app.survey + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.databinding.SurveyWelcomeDialogFragmentBinding +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.survey.SurveyController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import javax.inject.Inject + +const val TAG_SURVEY_WELCOME_DIALOG = "SURVEY_WELCOME_DIALOG" + +/** Presenter for [SurveyWelcomeDialogFragment], sets up bindings. */ +@FragmentScope +class SurveyWelcomeDialogFragmentPresenter @Inject constructor( + private val activity: AppCompatActivity, + private val fragment: Fragment, + private val surveyController: SurveyController, + private val oppiaLogger: OppiaLogger +) { + /** Sets up data binding. */ + fun handleCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + profileId: ProfileId, + topicId: String, + questionNames: List, + ): View { + val binding = + SurveyWelcomeDialogFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) + + binding.lifecycleOwner = fragment + + binding.beginSurveyButton.setOnClickListener { + startSurveySession(profileId, topicId, questionNames) + } + + binding.maybeLaterButton.setOnClickListener { + activity.supportFragmentManager.beginTransaction() + .remove(fragment) + .commitNow() + } + + return binding.root + } + + private fun startSurveySession( + profileId: ProfileId, + topicId: String, + questions: List + ) { + val startDataProvider = surveyController.startSurveySession(questions) + startDataProvider.toLiveData().observe( + activity, + { + when (it) { + is AsyncResult.Pending -> + oppiaLogger.d("SurveyWelcomeDialogFragment", "Starting a survey session") + is AsyncResult.Failure -> { + oppiaLogger.e( + "SurveyWelcomeDialogFragment", + "Failed to start a survey session", + it.error + ) + activity.finish() // Can't recover from the session failing to start. + } + is AsyncResult.Success -> { + oppiaLogger.d("SurveyWelcomeDialogFragment", "Successfully started a survey session") + val intent = + SurveyActivity.createSurveyActivityIntent(activity, profileId, topicId) + fragment.startActivity(intent) + activity.finish() + val transaction = activity.supportFragmentManager.beginTransaction() + transaction.remove(fragment).commitAllowingStateLoss() + } + } + } + ) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt new file mode 100644 index 00000000000..752fd524f5a --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt @@ -0,0 +1,60 @@ +package org.oppia.android.app.survey.surveyitemviewmodel + +import android.text.Editable +import android.text.TextWatcher +import androidx.databinding.Observable +import androidx.databinding.ObservableField +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver +import org.oppia.android.app.survey.SelectedAnswerHandler +import javax.inject.Inject + +class FreeFormItemsViewModel @Inject constructor( + private val answerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver, + private val questionName: SurveyQuestionName, + private val answerHandler: SelectedAnswerHandler +) : SurveyAnswerItemViewModel(ViewType.FREE_FORM_ANSWER) { + var answerText: CharSequence = "" + val isAnswerAvailable = ObservableField(false) + + init { + val callback: Observable.OnPropertyChangedCallback = + object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + answerAvailabilityReceiver.onPendingAnswerAvailabilityCheck( + answerText.isNotEmpty() + ) + } + } + isAnswerAvailable.addOnPropertyChangedCallback(callback) + } + + fun getAnswerTextWatcher(): TextWatcher { + return object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + } + + override fun onTextChanged(answer: CharSequence, start: Int, before: Int, count: Int) { + answerText = answer.toString().trim() + val isAnswerTextAvailable = answerText.isNotEmpty() + if (isAnswerTextAvailable != isAnswerAvailable.get()) { + isAnswerAvailable.set(isAnswerTextAvailable) + } + } + + override fun afterTextChanged(s: Editable) { + } + } + } + + fun handleSubmitButtonClicked() { + if (answerText.isNotEmpty()) { + val answer = SurveySelectedAnswer.newBuilder() + .setQuestionName(questionName) + .setFreeFormAnswer(answerText.toString()) + .build() + answerHandler.getFreeFormAnswer(answer) + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt new file mode 100644 index 00000000000..e2b7fe43a5e --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt @@ -0,0 +1,153 @@ +package org.oppia.android.app.survey.surveyitemviewmodel + +import androidx.databinding.Observable +import androidx.databinding.ObservableArrayList +import androidx.databinding.ObservableField +import androidx.databinding.ObservableList +import org.oppia.android.R +import org.oppia.android.app.model.MarketFitAnswer +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.app.survey.PreviousAnswerHandler +import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver +import org.oppia.android.app.survey.SelectedAnswerHandler +import org.oppia.android.app.translation.AppLanguageResourceHandler +import javax.inject.Inject + +/** [SurveyAnswerItemViewModel] for the market fit question options. */ +class MarketFitItemsViewModel @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler, + private val selectedAnswerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver, + private val answerHandler: SelectedAnswerHandler +) : SurveyAnswerItemViewModel(ViewType.MARKET_FIT_OPTIONS), PreviousAnswerHandler { + val optionItems: ObservableList = getMarketFitOptions() + + private val selectedItems: MutableList = mutableListOf() + + override fun updateSelection(itemIndex: Int): Boolean { + optionItems.forEach { item -> item.isAnswerSelected.set(false) } + if (!selectedItems.contains(itemIndex)) { + selectedItems.clear() + selectedItems += itemIndex + } else { + selectedItems.clear() + } + + updateIsAnswerAvailable() + + if (selectedItems.isNotEmpty()) { + getPendingAnswer(itemIndex) + } + + return selectedItems.isNotEmpty() + } + + val isAnswerAvailable = ObservableField(false) + + init { + val callback: Observable.OnPropertyChangedCallback = + object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + selectedAnswerAvailabilityReceiver.onPendingAnswerAvailabilityCheck( + selectedItems.isNotEmpty() + ) + } + } + isAnswerAvailable.addOnPropertyChangedCallback(callback) + } + + private fun updateIsAnswerAvailable() { + val selectedItemListWasEmpty = isAnswerAvailable.get() + if (selectedItems.isNotEmpty() != selectedItemListWasEmpty) { + isAnswerAvailable.set(selectedItems.isNotEmpty()) + } + } + + private fun getPendingAnswer(itemIndex: Int) { + val typeCase = itemIndex + 1 + val answerValue = MarketFitAnswer.forNumber(typeCase) + val answer = SurveySelectedAnswer.newBuilder() + .setQuestionName(SurveyQuestionName.MARKET_FIT) + .setMarketFit(answerValue) + .build() + answerHandler.getMultipleChoiceAnswer(answer) + } + + override fun getPreviousAnswer(): SurveySelectedAnswer { + return SurveySelectedAnswer.getDefaultInstance() + } + + override fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) { + // Index 0 corresponds to ANSWER_UNSPECIFIED which is not a valid option so it's filtered out. + // Valid enum type numbers start from 1 while list item indices start from 0, hence the minus(1) + // to get the correct index to update. Notice that for [getPendingAnswer] we increment the index + // to get the correct typeCase to save. + val previousSelection = previousAnswer.marketFit.number.takeIf { it != 0 }?.minus(1) + + selectedItems.apply { + clear() + previousSelection?.let { optionIndex -> + add(optionIndex) + updateIsAnswerAvailable() + getPendingAnswer(optionIndex) + optionItems[optionIndex].isAnswerSelected.set(true) + } + } + } + + private fun getMarketFitOptions(): ObservableList { + val appName = resourceHandler.getStringInLocale(R.string.app_name) + val observableList = ObservableArrayList() + observableList += MarketFitAnswer.values() + .filter { it.isValid() } + .mapIndexed { index, marketFitAnswer -> + when (marketFitAnswer) { + MarketFitAnswer.VERY_DISAPPOINTED -> MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.market_fit_answer_very_disappointed + ), + index, + this + ) + + MarketFitAnswer.DISAPPOINTED -> MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.market_fit_answer_somewhat_disappointed + ), + index, + this + ) + + MarketFitAnswer.NOT_DISAPPOINTED -> MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.market_fit_answer_not_disappointed + ), + index, + this + ) + + MarketFitAnswer.NOT_APPLICABLE_WONT_USE_OPPIA_ANYMORE -> + MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocaleWithWrapping( + R.string.market_fit_answer_wont_use_oppia, + appName + ), + index, + this + ) + else -> throw IllegalStateException("Invalid MarketFitAnswer") + } + } + return observableList + } + + companion object { + /** Returns whether a [MarketFitAnswer] is valid. */ + fun MarketFitAnswer.isValid(): Boolean { + return when (this) { + MarketFitAnswer.UNRECOGNIZED, MarketFitAnswer.MARKET_FIT_ANSWER_UNSPECIFIED -> false + else -> true + } + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt new file mode 100644 index 00000000000..be29f812ce0 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt @@ -0,0 +1,22 @@ +package org.oppia.android.app.survey.surveyitemviewmodel + +import androidx.databinding.ObservableBoolean +import org.oppia.android.app.viewmodel.ObservableViewModel + +/** [ObservableViewModel] for MultipleChoiceInput values. */ +class MultipleChoiceOptionContentViewModel( + val optionContent: String, + val itemIndex: Int, + private val optionsViewModel: SurveyAnswerItemViewModel +) : ObservableViewModel() { + var isAnswerSelected = ObservableBoolean() + + fun handleItemClicked() { + val isCurrentlySelected = isAnswerSelected.get() + val shouldNowBeSelected = + optionsViewModel.updateSelection(itemIndex) + if (isCurrentlySelected != shouldNowBeSelected) { + isAnswerSelected.set(shouldNowBeSelected) + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt new file mode 100644 index 00000000000..1469228b49a --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt @@ -0,0 +1,99 @@ +package org.oppia.android.app.survey.surveyitemviewmodel + +import androidx.databinding.Observable +import androidx.databinding.ObservableField +import androidx.databinding.ObservableList +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.app.survey.PreviousAnswerHandler +import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver +import org.oppia.android.app.survey.SelectedAnswerHandler +import org.oppia.android.app.viewmodel.ObservableArrayList +import javax.inject.Inject + +class NpsItemsViewModel @Inject constructor( + private val selectedAnswerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver, + private val answerHandler: SelectedAnswerHandler +) : SurveyAnswerItemViewModel(ViewType.NPS_OPTIONS), PreviousAnswerHandler { + val optionItems: ObservableList = getNpsOptions() + + private val selectedItems: MutableList = mutableListOf() + + override fun updateSelection(itemIndex: Int): Boolean { + optionItems.forEach { item -> item.isAnswerSelected.set(false) } + if (!selectedItems.contains(itemIndex)) { + selectedItems.clear() + selectedItems += itemIndex + } else { + selectedItems.clear() + } + + updateIsAnswerAvailable() + + if (selectedItems.isNotEmpty()) { + getPendingAnswer(itemIndex) + } + + return selectedItems.isNotEmpty() + } + + val isAnswerAvailable = ObservableField(false) + + init { + val callback: Observable.OnPropertyChangedCallback = + object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + selectedAnswerAvailabilityReceiver.onPendingAnswerAvailabilityCheck( + selectedItems.isNotEmpty() + ) + } + } + isAnswerAvailable.addOnPropertyChangedCallback(callback) + } + + private fun updateIsAnswerAvailable() { + val selectedItemListWasEmpty = isAnswerAvailable.get() + if (selectedItems.isNotEmpty() != selectedItemListWasEmpty) { + isAnswerAvailable.set(selectedItems.isNotEmpty()) + } + } + + private fun getPendingAnswer(npsScore: Int) { + val answer = SurveySelectedAnswer.newBuilder() + .setQuestionName(SurveyQuestionName.NPS) + .setNpsScore(npsScore) + .build() + answerHandler.getMultipleChoiceAnswer(answer) + } + + override fun getPreviousAnswer(): SurveySelectedAnswer { + return SurveySelectedAnswer.getDefaultInstance() + } + + override fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) { + val selectedAnswerOption = previousAnswer.npsScore + selectedItems.apply { + clear() + add(selectedAnswerOption) + } + + updateIsAnswerAvailable() + + selectedAnswerOption.let { optionIndex -> + getPendingAnswer(optionIndex) + optionItems[optionIndex].isAnswerSelected.set(true) + } + } + + private fun getNpsOptions(): ObservableArrayList { + val observableList = ObservableArrayList() + observableList += (0..10).mapIndexed { index, score -> + MultipleChoiceOptionContentViewModel( + optionContent = score.toString(), + itemIndex = index, + this + ) + } + return observableList + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt new file mode 100644 index 00000000000..85de88ec26f --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt @@ -0,0 +1,22 @@ +package org.oppia.android.app.survey.surveyitemviewmodel + +import org.oppia.android.app.viewmodel.ObservableViewModel + +/** + * The root [ObservableViewModel] for all individual items that may be displayed in the survey + * fragment recycler view. + */ +abstract class SurveyAnswerItemViewModel(val viewType: ViewType) : ObservableViewModel() { + + open fun updateSelection(itemIndex: Int): Boolean { + return true + } + + /** Corresponds to the type of the view model. */ + enum class ViewType { + MARKET_FIT_OPTIONS, + USER_TYPE_OPTIONS, + FREE_FORM_ANSWER, + NPS_OPTIONS + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt new file mode 100644 index 00000000000..14482c2775e --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt @@ -0,0 +1,153 @@ +package org.oppia.android.app.survey.surveyitemviewmodel + +import androidx.databinding.Observable +import androidx.databinding.ObservableField +import androidx.databinding.ObservableList +import org.oppia.android.R +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.app.model.UserTypeAnswer +import org.oppia.android.app.survey.PreviousAnswerHandler +import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver +import org.oppia.android.app.survey.SelectedAnswerHandler +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.viewmodel.ObservableArrayList +import javax.inject.Inject + +/** [SurveyAnswerItemViewModel] for providing the type of user question options. */ +class UserTypeItemsViewModel @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler, + private val selectedAnswerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver, + private val answerHandler: SelectedAnswerHandler +) : SurveyAnswerItemViewModel(ViewType.USER_TYPE_OPTIONS), PreviousAnswerHandler { + val optionItems: ObservableList = getUserTypeOptions() + + private val selectedItems: MutableList = mutableListOf() + + override fun updateSelection(itemIndex: Int): Boolean { + optionItems.forEach { item -> item.isAnswerSelected.set(false) } + if (!selectedItems.contains(itemIndex)) { + selectedItems.clear() + selectedItems += itemIndex + } else { + selectedItems.clear() + } + + updateIsAnswerAvailable() + + if (selectedItems.isNotEmpty()) { + getPendingAnswer(itemIndex) + } + + return selectedItems.isNotEmpty() + } + + val isAnswerAvailable = ObservableField(false) + + init { + val callback: Observable.OnPropertyChangedCallback = + object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + selectedAnswerAvailabilityReceiver.onPendingAnswerAvailabilityCheck( + selectedItems.isNotEmpty() + ) + } + } + isAnswerAvailable.addOnPropertyChangedCallback(callback) + } + + private fun updateIsAnswerAvailable() { + val selectedItemListWasEmpty = isAnswerAvailable.get() + if (selectedItems.isNotEmpty() != selectedItemListWasEmpty) { + isAnswerAvailable.set(selectedItems.isNotEmpty()) + } + } + + private fun getPendingAnswer(itemIndex: Int) { + val typeCase = itemIndex + 1 + val answerValue = UserTypeAnswer.forNumber(typeCase) + val answer = SurveySelectedAnswer.newBuilder() + .setQuestionName(SurveyQuestionName.USER_TYPE) + .setUserType(answerValue) + .build() + answerHandler.getMultipleChoiceAnswer(answer) + } + + override fun getPreviousAnswer(): SurveySelectedAnswer { + return SurveySelectedAnswer.getDefaultInstance() + } + + override fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) { + // Index 0 corresponds to ANSWER_UNSPECIFIED which is not a valid option so it's filtered out. + // Valid enum type numbers start from 1 while list item indices start from 0, hence the minus(1) + // to get the correct index to update. Notice that for [getPendingAnswer] we increment the index + // to get the correct typeCase to save. + val previousSelection = previousAnswer.userType.number.takeIf { it != 0 }?.minus(1) + + selectedItems.apply { + clear() + previousSelection?.let { optionIndex -> + add(optionIndex) + updateIsAnswerAvailable() + getPendingAnswer(optionIndex) + optionItems[optionIndex].isAnswerSelected.set(true) + } + } + } + + private fun getUserTypeOptions(): ObservableArrayList { + val observableList = ObservableArrayList() + observableList += UserTypeAnswer.values() + .filter { it.isValid() } + .mapIndexed { index, userTypeOption -> + when (userTypeOption) { + UserTypeAnswer.LEARNER -> + MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.user_type_answer_learner + ), + index, + this + ) + UserTypeAnswer.TEACHER -> MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.user_type_answer_teacher + ), + index, + this + ) + + UserTypeAnswer.PARENT -> + MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.user_type_answer_parent + ), + index, + this + ) + + UserTypeAnswer.OTHER -> + MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.user_type_answer_other + ), + index, + this + ) + else -> throw IllegalStateException("Invalid UserTypeAnswer") + } + } + return observableList + } + + companion object { + + /** Returns whether a [UserTypeAnswer] is valid. */ + fun UserTypeAnswer.isValid(): Boolean { + return when (this) { + UserTypeAnswer.UNRECOGNIZED, UserTypeAnswer.USER_TYPE_UNSPECIFIED -> false + else -> true + } + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt b/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt index d9883ed21c5..08726cc7f49 100644 --- a/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt +++ b/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt @@ -8,11 +8,14 @@ import org.oppia.android.app.customview.ContinueButtonView import org.oppia.android.app.customview.LessonThumbnailImageView import org.oppia.android.app.customview.PromotedStoryCardView import org.oppia.android.app.customview.SegmentedCircularProgressView +import org.oppia.android.app.customview.SurveyOnboardingBackgroundView import org.oppia.android.app.home.promotedlist.ComingSoonTopicsListView import org.oppia.android.app.home.promotedlist.PromotedStoryListView import org.oppia.android.app.player.state.DragDropSortInteractionView import org.oppia.android.app.player.state.ImageRegionSelectionInteractionView import org.oppia.android.app.player.state.SelectionInteractionView +import org.oppia.android.app.survey.SurveyMultipleChoiceOptionView +import org.oppia.android.app.survey.SurveyNpsItemOptionView // TODO(#59): Restrict access to this implementation by introducing injectors in each view. @@ -39,4 +42,7 @@ interface ViewComponentImpl : ViewComponent { fun inject(promotedStoryCardView: PromotedStoryCardView) fun inject(promotedStoryListView: PromotedStoryListView) fun inject(segmentedCircularProgressView: SegmentedCircularProgressView) + fun inject(surveyOnboardingBackgroundView: SurveyOnboardingBackgroundView) + fun inject(surveyMultipleChoiceOptionView: SurveyMultipleChoiceOptionView) + fun inject(surveyNpsItemOptionView: SurveyNpsItemOptionView) } diff --git a/app/src/main/res/color/component_color_shared_survey_option_selector.xml b/app/src/main/res/color/component_color_shared_survey_option_selector.xml new file mode 100644 index 00000000000..fe393056c34 --- /dev/null +++ b/app/src/main/res/color/component_color_shared_survey_option_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_button_white_background_color.xml b/app/src/main/res/drawable/rounded_button_white_background_color.xml new file mode 100644 index 00000000000..a11197bd37a --- /dev/null +++ b/app/src/main/res/drawable/rounded_button_white_background_color.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_button_white_outline_color.xml b/app/src/main/res/drawable/rounded_button_white_outline_color.xml new file mode 100644 index 00000000000..9745cd92363 --- /dev/null +++ b/app/src/main/res/drawable/rounded_button_white_outline_color.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml b/app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml new file mode 100644 index 00000000000..cb6db57bdcf --- /dev/null +++ b/app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/survey_confirmation_dialog_background.xml b/app/src/main/res/drawable/survey_confirmation_dialog_background.xml new file mode 100644 index 00000000000..a8e76a82fd6 --- /dev/null +++ b/app/src/main/res/drawable/survey_confirmation_dialog_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/survey_edit_text_background_border.xml b/app/src/main/res/drawable/survey_edit_text_background_border.xml new file mode 100644 index 00000000000..84258c626cf --- /dev/null +++ b/app/src/main/res/drawable/survey_edit_text_background_border.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/survey_next_button_background.xml b/app/src/main/res/drawable/survey_next_button_background.xml new file mode 100644 index 00000000000..de55a5e10e0 --- /dev/null +++ b/app/src/main/res/drawable/survey_next_button_background.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/survey_nps_radio_button_background.xml b/app/src/main/res/drawable/survey_nps_radio_button_background.xml new file mode 100644 index 00000000000..57745803dad --- /dev/null +++ b/app/src/main/res/drawable/survey_nps_radio_button_background.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/survey_nps_radio_selected_color.xml b/app/src/main/res/drawable/survey_nps_radio_selected_color.xml new file mode 100644 index 00000000000..c62b0773a38 --- /dev/null +++ b/app/src/main/res/drawable/survey_nps_radio_selected_color.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/survey_nps_radio_text_color.xml b/app/src/main/res/drawable/survey_nps_radio_text_color.xml new file mode 100644 index 00000000000..4d28037d434 --- /dev/null +++ b/app/src/main/res/drawable/survey_nps_radio_text_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml b/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml new file mode 100644 index 00000000000..b2665b4d899 --- /dev/null +++ b/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/survey_progress_bar.xml b/app/src/main/res/drawable/survey_progress_bar.xml new file mode 100644 index 00000000000..f31fa6cfdf6 --- /dev/null +++ b/app/src/main/res/drawable/survey_progress_bar.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/survey_rounded_disabled_button_background.xml b/app/src/main/res/drawable/survey_rounded_disabled_button_background.xml new file mode 100644 index 00000000000..b58f04d8764 --- /dev/null +++ b/app/src/main/res/drawable/survey_rounded_disabled_button_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/survey_submit_button_background.xml b/app/src/main/res/drawable/survey_submit_button_background.xml new file mode 100644 index 00000000000..f59c05a5547 --- /dev/null +++ b/app/src/main/res/drawable/survey_submit_button_background.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/layout/survey_activity.xml b/app/src/main/res/layout/survey_activity.xml new file mode 100644 index 00000000000..351b90d614e --- /dev/null +++ b/app/src/main/res/layout/survey_activity.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/app/src/main/res/layout/survey_exit_confirmation_dialog.xml b/app/src/main/res/layout/survey_exit_confirmation_dialog.xml new file mode 100644 index 00000000000..78128ced3dd --- /dev/null +++ b/app/src/main/res/layout/survey_exit_confirmation_dialog.xml @@ -0,0 +1,39 @@ + + + + + + + +