From 0290ef8037f798d8ba2721441612a80dec4a1f1a Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Fri, 10 Mar 2023 00:39:16 -0800 Subject: [PATCH] Fix #4833, #4834, #4835, #4838, #1050, #4519, #4522, #4837, #4836, #4855, #4856: Assorted alpha MR6 fixes (#4846) ## Explanation Fixes #4833 Fixes #4834 Fixes #4835 Fixes #4838 Fixes #1050 Fixes #4519 Fixes #4522 Fixes #4837 Fixes #4836 Fixes #4855 Fixes #4856 This PR introduces a variety of fixes and user quality-of-life improvements in preparation for the start of the upcoming multi-week user research project in Kenya. This PR covers 9 different issues in one shot to keep the PR management simpler due to the time sensitive nature of these changes, so it's on the larger side. Please note that: - These changes have been released to a user study specific release track since the research project is currently ongoing. - Many of these changes will impact users outside of the study, as detailed below. The specifics of the PR will be split across the different issues being addressed, in categories below. ### Improvements to solutions #4833 needed to be addressed because the previous terminology ("reveal") has been found to be confusing to learners who might not yet have a strong grasp on English. This string update will, unfortunately, not be translated for the upcoming study (though the study predominantly uses English). This change affects all users of the app. #1050 is a long-standing issue wherein solution answers would only show up correctly if they were either text or a fraction. This PR fixes the issue by introducing generic answer support, expanding it to include: numeric input, numeric expression, algebraic expression, algebraic equation, and ratio expression. The only missing interaction that web supports is drag & drop, but this has been found to not be needed for the current shipped list of topics (as now enforced by an update to the content import pipeline) and introducing support for it would nontrivially increase the complexity of that side of the PR. This is something that, like other interactions, will be added in the future once needed. This fix will affect all users of the app. ### Improvements to voiceover telemetry #4834 was addressed by introducing a new event to track explicit voiceover pauses (i.e. those that require clicking the 'pause' button and not the ones that are automatic, such as autoplay ending or the user navigating away from the lesson player). Because of the nature of the 'play voiceover' event (that it is logged both for clicking the 'play' button and when expanding the audio bar due to autoplay), it's possible for the play & pause event counts to not be equal. The intent behind these events is to track explicit user actions, not the occurrences of voiceovers playing/not playing. This event will be logged for all users, though sensitive identifiers will only be logged if the user study feature is enabled. #4835 was addressed by updating both the existing 'play voiceover' and new 'pause vocieover' events to include the lesson player-reported language code (that is, the language code originating from Oppia web). We may change this to be something more strongly ensured (since Oppia Android is a bit more careful in its language code management due to Android complexities), but the code should be generally usable as-is for data purposes as it's implemented. This change will affect telemetry for all users of the app. ### Improvements to concept card availability #4519 and #4522 were addressed by adding support for opening concept cards both in concept cards themselves, and from hints. As of this PR, just about all cases of lesson HTML rendering should now include support for linkifying and opening concept card references. Since both hints and concept cards are dialogs, the behavior is that the newly opened concept card dialog "stacks" on top of the existing, open dialog (i.e. closing the concept card will show the original dialog from which it was opened). This change affects all users of the app. ### Miscellaneous language improvements #4837 was addressed to clean up a variety of extraneous spaces & newlines that were unintentionally added by translators for Swahili strings. These have a direct user benefit but are otherwise innocuous, non-semantic changes. This fix technically affects all users of the app, but only those who use the Swahili translations will actually interact with the changed strings. ### Study-specific improvements #4838 was addressed by introducing a new link in the profile edit flow for administrators to access the previously developer-only flow for manually marking certain chapters as completed. This newly exposed mechanism allows study facilitators to set up profiles so that learners can begin at a specific point (which is particularly helpful if the data on the device ever needs to be reset, such as in the case where the administrator forgot their PIN). This change will only affect user study administrators as it's locked both behind the admin account and the learner study flag. The functionality *will* be available on production-optimized builds (that is, it won't be restricted to developer-only build flavors of the app). #4836 was addressed by introducing a floating button within the lesson player for lesson cards whose content have available Swahili translations to quickly switch the content language between English and Swahili. This doesn't change app strings, only content strings, and only temporarily for the lifetime of that specific play session (that is, if they exit the lesson & return immediately they will still be in the switched language until the app itself restarts). This button only appears if the study feature is enabled and has been enabled for that user by the administrator profile (via a new setting on the "Edit Profile" screen). This button also has its own new event to track its usage. A "share IDs" button was added to make it easier to share the device & learner IDs by collating the IDs into a single blob of text that can be shared to any app that accepts text intents. This is meant to help reduce potential error by study coordinators. ### Content display improvements #4855 was addressed by changing a lot of how custom LI/OL tags are handled. Specifically, Android will automatically shift the leading span of cascading bullets/numbers, but the previous implementation was using the wrong value for each nested span's parent's leading margin (which is needed to adjust what leading margin to use for the child span). This was fixed by introducing more thorough tracking (which also included a bit of robustness in ensuring larger numbers can be aligned correctly). This doesn't quite match HTML rendering, but it's much closer. Bullet lists were also updated to use squares in addition to filled and open circles (for parity with web HTML), and now uses sizes & spacing that are a bit cleaner to see. There's still more work to improve edge cases for bullet list rendering (see #4859). #4856 was addressed by introducing a separate SVG "render size" that, unlike the intrinsic size, is based on the image filename. Since the image filename is given in pixels, there needs to be a translation to dp to ensure consistent viewing across devices. This computation has been implemented with three calculation passes: 1. Convert the image's target pixel size to "Oppia independent pixels" by using @seanlip's monitor display density as the basis. 2. Convert "Oppia independent pixels" into Android density-independent pixels. 3. Adjust the final value using a scalar (since the physical size of an image on @seanlip's display is too large for a small mobile screen, so all images are consistently further scaled down). There's still more work to improve image rendering (see #4093). ### General technical details Design choices & other details of note: - The chapter completion flow was updated to support configuring a confirmation dialog (since the action is permanent). This confirmation only shows for the admin edit profile flow (the developer options menu version still does not show a confirmation). - The hints & solution data flow was completely redone in the app layer since much of what it was doing before isn't actually necessary with ``HelpIndex`` as it greatly reduces the complexity of managing UI-side hint state. Since solutions were being updated, anyway, this unrelated complexity has been reduced (and other issues like directly constructing an injectable view model were addressed). - Solution titles were previously set to their content ID (similar to hints before this was recently fixed), so this was addressed by using a new "Solution" string. - Displayed solution answers didn't correctly differentiate between exclusive & example answers. This has been corrected in text (along with how the app generally concatenates the text as the previous approach wasn't very RTL friendly). - Generalizing solutions was done by leveraging an InteractionObject for Solution's correct_answer field. No migration is being done here since assets are replaced across releases. - There are a lot of changes to both json and textproto assets to accommodate the new correct_answer structure, among other various changes. A few of these were done manually, but most were generated (including new Swahili translations to allow local testing of the in-lesson language switch button). - A bug was noticed & fixed wherein states with only a solution (and no hints) would never show the solution. - Due to the concatenation strategy for solution correct answer text (see a few points above), the horizontal textual alignment was a bit off. The new approach addresses this (though LaTeX is still a bit off, but it's generally off everywhere in the app). - To make the new in-lesson switch language button a bit more consistent and aesthetically pleasing, this PR repurposes the "Start over" button that currently shows on the checkpoint continue screen by making it a generic "secondary button" style (with corresponding colors) and using that both for the start over button and this new secondary button. Per the screenshots below, this seems to work well. The secondary button may also be used elsewhere in the app in the future. - Note that some of the work of this PR originates from #4529, so it can be considered a replacement for that PR. - Only the first test topic was updated to include Swahili translations to help decrease the size of the PR (plus, it's convenient to have non-Swahili lessons to verify cases when the switch button does **not** show up). - The PR fixes an incidental issue with the hints & solution dialog whereby clicking any part of an expanded hint/solution card would collapse it (rather than just the header or collapse icon). This would make clicking links more difficult, and is just generally a likely unexpected behavior from users. - The new event log & language code property have been manually verified as logging correctly using the developer Firebase project with debug view enabled. - A new event was added to track when users leave revision cards. - A bug was found & fixed that led to tab switches in the topic activity not having their corresponding events properly logged. - All events have been updated to now include app string, content string, and voiceover languages per-profile for that event (which led to a fairly large amount of complexity around providing access to those languages at a low level like ``AnalyticsController``). - A bug was found & fixed around session ID management: previously, the session ID was supposed to be reset upon a new lesson being started but it actually wasn't behaving this way. Per the new needs of analytics, this behavior was changed & fixed to reset upon profile login (so that behaviors for a single user session can be properly correlated). Noteworthy test & exemption changes: - ``StateRetrieverTest`` is largely not updated since all of the JSON changes in this PR only affect local development (all Bazel tests & production builds use textproto or binary proto versions of lessons). The tests locally passing plus some basic local ad hoc testing is considered sufficient for the JSON loading pipeline as it will, eventually, go away in favor of textproto (once Gradle is removed since textproto -> binary proto conversion is not easily implemented in the Gradle build environment). - Some of the question test suites weren't updated since questions aren't currently enabled, and much of the question pipeline is already covered under state fragment tests. It's likely that the testing matrix for questions will need to be largely refined once they're ready to be enabled, so some of this work was skipped in favor of expediency. - Some tests in StateFragmentTest & StateFragmentLocalTest are disabled and can't easily be verified because they would only pass on Espresso and Bazel (due to Robolectric having limitations in language resource handling & Gradle builds not supporting the app's language configurations). Since Espresso tests do not yet work in Bazel, these tests will be verified later (though they have been manually verified as part of development). - A bunch of KDoc validity exemptions were removed since those classes are now, as of this PR, fully documented. - The test_file_exemptions updates are due to HintsViewModel being split into two new view models, and the general convention on the team to not directly test view models. ## 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 ### Screenshots showing some of the new functionality | **Scenario** | LTR | LTR | LTR | LTR | RTL | RTL | RTL | RTL | |----------------------------------------------|----------|-----------|----------|-----------|----------|-----------|----------|-----------| | | Handset | Handset | Tablet | Tablet | Handset | Handset | Tablet | Tablet | | | Portrait | Landscape | Portrait | Landscape | Portrait | Landscape | Portrait | Landscape | | Fractions solution (exclusive) | ![image](https://user-images.githubusercontent.com/12983742/213827471-4efb3c0d-b4fc-4a7b-a892-7467535ef67a.png) | ![image](https://user-images.githubusercontent.com/12983742/213827548-ca856160-30e3-4e1a-a423-91e241000fc3.png) | Skipped until requested | Skipped until requested | ![image](https://user-images.githubusercontent.com/12983742/213828637-dc6a6b78-e36c-4829-a04f-3a69a3035b4b.png) | ![image](https://user-images.githubusercontent.com/12983742/213828651-23419f3e-64bb-495c-b0b5-27f17f1eb2fc.png) | Skipped until requested | Skipped until requested | | Numeric solution (exclusive) | ![image](https://user-images.githubusercontent.com/12983742/213827605-4b767195-a9a2-4790-9156-b5f18edbd4ed.png) | ![image](https://user-images.githubusercontent.com/12983742/213827632-2e02495d-83e6-48ad-8652-ba97a3e2c3e2.png) | Skipped until requested | Skipped until requested | ![image](https://user-images.githubusercontent.com/12983742/213828696-7b8367dc-5f56-4a3a-ad00-441610915e66.png) | ![image](https://user-images.githubusercontent.com/12983742/213828705-aa6651bb-ee77-46e0-a3e2-612e340254a9.png) | Skipped until requested | Skipped until requested | | Ratio solution (not exclusive) | ![image](https://user-images.githubusercontent.com/12983742/213827665-bc2c4fc3-f77f-4a3e-9817-c6a4eaf3a435.png) | ![image](https://user-images.githubusercontent.com/12983742/213827677-05f004d0-c6f1-46ea-bc91-afbe036654a5.png) | Skipped until requested | Skipped until requested | ![image](https://user-images.githubusercontent.com/12983742/213828734-af173be7-6e88-43b3-aeff-c3da4f7f220d.png) | ![image](https://user-images.githubusercontent.com/12983742/213828745-03d91530-fcbb-4205-b091-e638b818f056.png) | Skipped until requested | Skipped until requested | | Text solution (exclusive) | ![image](https://user-images.githubusercontent.com/12983742/213827705-5d6cd4c5-50a6-46b7-88a2-709e8c6eb981.png) | ![image](https://user-images.githubusercontent.com/12983742/213827720-2e5982d2-6a41-4ec9-b5f2-26d714e27bff.png) | Skipped until requested | Skipped until requested | ![image](https://user-images.githubusercontent.com/12983742/213828781-17764229-8a6f-4bb8-a3c3-1dc30207e322.png) | ![image](https://user-images.githubusercontent.com/12983742/213828793-feeeea82-8475-4bbb-946c-b84c9a0dd988.png) | Skipped until requested | Skipped until requested | | Numeric expression solution (not exclusive) | ![image](https://user-images.githubusercontent.com/12983742/213827798-19adb687-ba51-4c53-abf8-6cb2a70f8d6e.png) | ![image](https://user-images.githubusercontent.com/12983742/213827818-f9108646-2182-459f-a236-f798d6e73814.png) | Skipped until requested | Skipped until requested | ![image](https://user-images.githubusercontent.com/12983742/213828847-4a4c9433-29bc-4bd9-8439-e922cbb41b4f.png) | ![image](https://user-images.githubusercontent.com/12983742/213828860-caa1f397-8410-4676-8f22-901ba3614465.png) | Skipped until requested | Skipped until requested | | Algebraic expression solution (not exclusive) | ![image](https://user-images.githubusercontent.com/12983742/213827838-42768b22-a5a4-4065-906e-96ced3c4a09d.png) | ![image](https://user-images.githubusercontent.com/12983742/213827851-98f2c08c-52fc-4eeb-a8dc-3068dc9df160.png) | Skipped until requested | Skipped until requested | ![image](https://user-images.githubusercontent.com/12983742/213828978-461c2788-1295-4ad7-8a3c-3cba79fa1429.png) | ![image](https://user-images.githubusercontent.com/12983742/213828991-2bbf31bf-8c41-4d67-94b6-e153943246cb.png) | Skipped until requested | Skipped until requested | | Math equation solution (not exclusive) | ![image](https://user-images.githubusercontent.com/12983742/213827886-4feeb3bd-6286-494b-8092-f6e0bc56846a.png) | ![image](https://user-images.githubusercontent.com/12983742/213827899-1b91d8ca-2bcc-4d53-b17a-304e1a2a2260.png) | Skipped until requested | Skipped until requested | ![image](https://user-images.githubusercontent.com/12983742/213829068-b0ffa99c-a19f-4682-bbb3-218cd43d9733.png) | ![image](https://user-images.githubusercontent.com/12983742/213829077-14e7b2ff-4c9a-4402-86bf-785cdf6de95f.png) | Skipped until requested | Skipped until requested | | Switch English to Swahili | ![image](https://user-images.githubusercontent.com/12983742/213827944-a3ce6d77-05a4-4ece-8d48-fad1af101ffa.png) | ![image](https://user-images.githubusercontent.com/12983742/213827953-3082154d-43fd-42c2-9b5c-2ecb5defae27.png) | Skipped until requested | Skipped until requested | ![image](https://user-images.githubusercontent.com/12983742/213829090-2bef2148-7a98-451b-adfc-bd6fec529153.png) | ![image](https://user-images.githubusercontent.com/12983742/213829099-3c64b6e7-2aa4-44c9-b4f3-22a30f389fde.png) | Skipped until requested | Skipped until requested | | Switch Swahili to English | ![image](https://user-images.githubusercontent.com/12983742/213827966-df757f7b-9148-4391-8768-ac0202a82a32.png) | ![image](https://user-images.githubusercontent.com/12983742/213827978-6c7a4ffd-1a2c-4a88-be60-90341f24110f.png) | Skipped until requested | Skipped until requested | ![image](https://user-images.githubusercontent.com/12983742/213829129-d87d7109-7e17-4b7a-81c0-d80b7a7ac3bd.png) | ![image](https://user-images.githubusercontent.com/12983742/213829143-43b44f5d-1a46-4872-a15e-081fdb7fa440.png) | Skipped until requested | Skipped until requested | | Mark chapters as completed (in profile edit) | ![image](https://user-images.githubusercontent.com/12983742/213828020-c6ab793a-6ab6-4fdb-a243-7804f94b7931.png) | ![image](https://user-images.githubusercontent.com/12983742/213828127-0f9c5015-692f-4b02-81f2-bf2ba2d419b0.png) | ![image](https://user-images.githubusercontent.com/12983742/213832145-17012205-7e66-4e63-b30a-27cb66b84bc5.png) | ![image](https://user-images.githubusercontent.com/12983742/213832158-35ce3573-d5fb-46c0-86b1-328ec45f935a.png) | ![image](https://user-images.githubusercontent.com/12983742/213828513-ff94895f-0d38-4747-9139-e121e44e7d0e.png) | ![image](https://user-images.githubusercontent.com/12983742/213828526-7a3a0db0-a385-46ed-b6f8-a853329cdf6c.png) | Skipped until requested | Skipped until requested | A couple things to note: - Switching from English to Swahili & back is a bit awkward in the RTL screenshots (partly because the RTL layout is maintained despite switching the content language). This isn't actually a realistic scenario to occur, but it's technically possible so demonstrating it above still makes sense. There are no plans to refine this flow. - The landscape screenshots for the profile edit screens clearly show a broken "Profile Deletion" button placement. This is unfortunately an issue already on develop, and #4852 has been filed to track this. ### Videos demonstrating different changed/new flows | Scenario | Video demonstration | |----------------------------------------------------|---------------------| | Switch language in-lesson | https://user-images.githubusercontent.com/12983742/213829720-075cbb64-cc5a-4a08-ab3b-f6d123650fac.mp4 | | Mark chapters completed | https://user-images.githubusercontent.com/12983742/213829850-e0e157c1-520b-4ff1-86f8-470eb8c37536.mp4 | | Open concept card from hints & other concept cards | https://user-images.githubusercontent.com/12983742/213829894-28228cf9-4c0a-4eca-a617-325676b8d538.mp4 | | Accessibility flow for solutions (fractions, numeric, ratios, text) | https://user-images.githubusercontent.com/12983742/213830378-a56235c2-bc78-4c1f-9248-9531ae0f024d.mp4 | | Accessibility flow for solutions (numeric expressions, algebraic expressions, math equations) | https://user-images.githubusercontent.com/12983742/213830403-b964485b-9b49-43b8-9dfd-35f904c0974e.mp4 | ### Espresso test results The following tests have been modified as a result of this PR: - MarkChaptersCompletedActivityTest - MarkChaptersCompletedFragmentTest - ExplorationActivityTest - StateFragmentTest - ProfileEditFragmentTest - StoryFragmentTest - NavigationDrawerActivityDebugTest - ConceptCardFragmentTest I have **not** run the Espresso tests for these suites. I ran into some issues getting the suites to kick off locally, and I won't have time to investigate this for a bit due to other competing priorities. Reviewers: please let me know if you would like these run and I will prioritize accordingly (I'm leaning toward not running them since many of the Espresso tests are already failing on develop, and we have no viable way to keep them passing until we can run them in CI). --- app/BUILD.bazel | 7 +- .../ProfileAndDeviceIdFragmentPresenter.kt | 43 +- .../learneranalytics/ProfileListViewModel.kt | 24 +- .../learneranalytics/ShareIdsViewModel.kt | 58 ++ .../devoptions/DeveloperOptionsActivity.kt | 9 +- .../markchapterscompleted/ChapterSelector.kt | 13 - .../MarkChaptersCompletedActivity.kt | 21 +- .../MarkChaptersCompletedActivityPresenter.kt | 6 +- .../MarkChaptersCompletedFragment.kt | 34 +- .../MarkChaptersCompletedFragmentPresenter.kt | 124 ++-- .../MarkChaptersCompletedTestActivity.kt | 22 +- .../testing/DeveloperOptionsTestActivity.kt | 5 +- .../app/hintsandsolution/HintViewModel.kt | 29 + .../HintsAndSolutionDialogFragment.kt | 12 +- ...HintsAndSolutionDialogFragmentPresenter.kt | 276 ++++----- .../HintsAndSolutionViewModel.kt | 121 ++++ .../app/hintsandsolution/HintsViewModel.kt | 122 ---- .../ReturnToLessonViewModel.kt | 2 +- .../app/hintsandsolution/SolutionViewModel.kt | 282 ++++++++- .../android/app/home/HomeFragmentPresenter.kt | 8 +- .../app/player/audio/AudioViewModel.kt | 8 +- .../player/exploration/ExplorationActivity.kt | 9 +- .../ExplorationFragmentPresenter.kt | 8 +- ...tionExplorationManagerFragmentPresenter.kt | 5 +- .../player/state/StateFragmentPresenter.kt | 14 +- .../app/player/state/StateViewModel.kt | 146 ++++- .../testing/StateFragmentTestActivity.kt | 8 +- .../ProfileChooserFragmentPresenter.kt | 7 +- .../settings/profile/ProfileEditActivity.kt | 2 +- .../profile/ProfileEditFragmentPresenter.kt | 66 ++- .../settings/profile/ProfileEditViewModel.kt | 13 +- .../app/story/StoryFragmentPresenter.kt | 16 +- .../app/topic/TopicFragmentPresenter.kt | 38 +- .../ConceptCardFragmentPresenter.kt | 59 +- .../questionplayer/QuestionPlayerActivity.kt | 33 +- .../QuestionPlayerActivityPresenter.kt | 41 ++ .../QuestionPlayerFragmentPresenter.kt | 9 +- .../ReturnToTopicClickListener.kt | 5 +- .../revisioncard/RevisionCardActivity.kt | 9 +- .../RevisionCardActivityPresenter.kt | 11 +- .../RevisionCardFragmentPresenter.kt | 7 +- .../oppia/android/app/translation/BUILD.bazel | 1 + ...nd.xml => secondary_button_background.xml} | 2 +- .../res/layout-land/profile_edit_fragment.xml | 83 ++- .../{hints_summary.xml => hint_summary.xml} | 7 +- .../layout/hints_and_solution_fragment.xml | 2 +- .../main/res/layout/profile_edit_fragment.xml | 83 ++- .../layout/profile_list_share_ids_item.xml | 27 + app/src/main/res/layout/solution_summary.xml | 39 +- app/src/main/res/layout/state_fragment.xml | 111 ++-- .../main/res/values-night/color_palette.xml | 2 +- app/src/main/res/values-sw/strings.xml | 18 +- app/src/main/res/values/color_palette.xml | 2 +- app/src/main/res/values/component_colors.xml | 2 +- app/src/main/res/values/strings.xml | 14 +- app/src/main/res/values/styles.xml | 12 +- .../main/res/values/untranslated_strings.xml | 8 + .../ProfileAndDeviceIdActivityTest.kt | 2 +- .../ProfileAndDeviceIdFragmentTest.kt | 46 +- .../MarkChaptersCompletedActivityTest.kt | 91 ++- .../MarkChaptersCompletedFragmentTest.kt | 508 ++++++++++------ .../devoptions/ViewEventLogsFragmentTest.kt | 45 +- .../exploration/ExplorationActivityTest.kt | 4 +- .../android/app/player/state/BUILD.bazel | 1 + .../app/player/state/StateFragmentTest.kt | 512 +++++++++++++--- .../profile/ProfileEditFragmentTest.kt | 280 +++++---- .../android/app/story/StoryFragmentTest.kt | 12 +- .../NavigationDrawerActivityDebugTest.kt | 2 +- .../android/app/topic/TopicFragmentTest.kt | 128 +++- .../conceptcard/ConceptCardFragmentTest.kt | 136 +++-- .../revisioncard/RevisionCardActivityTest.kt | 137 ++++- .../android/app/home/HomeActivityLocalTest.kt | 9 +- .../parser/ListItemLeadingMarginSpanTest.kt | 111 ++-- .../player/state/StateFragmentLocalTest.kt | 476 ++++++++++++--- .../ProfileChooserFragmentLocalTest.kt | 9 +- .../app/story/StoryActivityLocalTest.kt | 9 +- .../topic/info/TopicInfoFragmentLocalTest.kt | 9 +- .../lessons/TopicLessonsFragmentLocalTest.kt | 9 +- .../RevisionCardActivityLocalTest.kt | 11 +- domain/src/main/assets/13.json | 60 ++ domain/src/main/assets/13.textproto | 90 ++- domain/src/main/assets/2mzzFVDLuAj8.json | 78 ++- domain/src/main/assets/5NWuolNcwH6e.json | 70 ++- domain/src/main/assets/5NWuolNcwH6e.textproto | 7 +- domain/src/main/assets/GJ2rLXRKD5hw_1.json | 6 +- .../src/main/assets/GJ2rLXRKD5hw_1.textproto | 6 +- domain/src/main/assets/GJ2rLXRKD5hw_2.json | 6 +- .../src/main/assets/GJ2rLXRKD5hw_2.textproto | 4 +- domain/src/main/assets/GJ2rLXRKD5hw_3.json | 6 +- .../src/main/assets/GJ2rLXRKD5hw_3.textproto | 4 +- domain/src/main/assets/GJ2rLXRKD5hw_4.json | 6 +- .../src/main/assets/GJ2rLXRKD5hw_4.textproto | 4 +- domain/src/main/assets/MjZzEVOG47_1.json | 144 +++-- domain/src/main/assets/MjZzEVOG47_1.textproto | 101 ++-- domain/src/main/assets/k2bQ7z5XHNbK.json | 72 ++- domain/src/main/assets/omzF4oqgeTXd_1.json | 6 +- .../src/main/assets/omzF4oqgeTXd_1.textproto | 4 +- domain/src/main/assets/questions.json | 90 ++- domain/src/main/assets/questions.textproto | 55 +- domain/src/main/assets/skills.json | 57 +- domain/src/main/assets/skills.textproto | 68 ++- domain/src/main/assets/tIoSb3HZFN6e.json | 67 ++- domain/src/main/assets/tIoSb3HZFN6e.textproto | 7 +- .../test_checkpointing_base_exploration.json | 9 +- ...t_checkpointing_base_exploration.textproto | 5 - ...ploration_multiple_compatible_updates.json | 9 +- ...tion_multiple_compatible_updates.textproto | 5 - ...ion_multiple_updates_one_incompatible.json | 9 +- ...ultiple_updates_one_incompatible.textproto | 5 - ...t_checkpointing_exploration_new_title.json | 9 +- ...ckpointing_exploration_new_title.textproto | 5 - ...checkpointing_exploration_new_version.json | 9 +- ...pointing_exploration_new_version.textproto | 5 - ..._updated_first_state_compat_rule_spec.json | 9 +- ...ted_first_state_compat_rule_spec.textproto | 5 - ...updated_first_state_correctness_label.json | 9 +- ...ed_first_state_correctness_label.textproto | 5 - ...ation_updated_first_state_destination.json | 9 +- ..._updated_first_state_destination.textproto | 5 - ...ion_updated_first_state_feedback_html.json | 9 +- ...pdated_first_state_feedback_html.textproto | 5 - ...ation_updated_first_state_feedback_id.json | 9 +- ..._updated_first_state_feedback_id.textproto | 5 - ...pdated_first_state_incompat_rule_spec.json | 9 +- ...d_first_state_incompat_rule_spec.textproto | 5 - ..._updated_first_state_interaction.textproto | 3 +- ...pdated_first_state_new_correct_answer.json | 9 +- ...d_first_state_new_correct_answer.textproto | 5 - ...ion_updated_first_state_updated_hints.json | 9 +- ...pdated_first_state_updated_hints.textproto | 5 - ...loration_updated_second_state_removed.json | 9 +- ...ion_updated_second_state_removed.textproto | 5 - domain/src/main/assets/test_exp_id_2.json | 456 ++++++++++++++- .../src/main/assets/test_exp_id_2.textproto | 545 +++++++++++++++++- domain/src/main/assets/test_exp_id_4.json | 6 +- domain/src/main/assets/test_exp_id_5.json | 397 ++++++++++++- .../src/main/assets/test_exp_id_5.textproto | 462 +++++++++++++++ ...ive_state_exp_with_hints_and_solution.json | 4 +- ...tate_exp_with_hints_and_solution.textproto | 3 +- ..._state_exp_with_one_hint_and_solution.json | 4 +- ...e_exp_with_one_hint_and_solution.textproto | 3 +- ...eractive_state_exp_with_only_solution.json | 4 +- ...ive_state_exp_with_only_solution.textproto | 3 +- ...tate_exp_with_solution_missing_answer.json | 1 + ...exp_with_solution_missing_answer.textproto | 1 - domain/src/main/assets/test_story_id_0.json | 4 +- domain/src/main/assets/test_topic_id_0_1.json | 31 +- .../main/assets/test_topic_id_0_1.textproto | 34 +- domain/src/main/assets/umPkwp0L1M0-.json | 115 ++-- domain/src/main/assets/umPkwp0L1M0-.textproto | 44 +- domain/src/main/assets/wANbh4oOClga.json | 8 +- .../domain/audio/AudioPlayerController.kt | 21 +- .../ExplorationProgressController.kt | 39 +- .../hintsandsolution/HelpIndexExtensions.kt | 23 +- .../android/domain/oppialogger/BUILD.bazel | 1 - .../LoggingIdentifierController.kt | 4 +- .../android/domain/oppialogger/OppiaLogger.kt | 46 +- .../analytics/AnalyticsController.kt | 80 ++- .../analytics/ApplicationLifecycleObserver.kt | 6 +- .../domain/oppialogger/analytics/BUILD.bazel | 2 + .../analytics/LearnerAnalyticsLogger.kt | 138 ++++- .../profile/ProfileManagementController.kt | 111 +++- .../android/domain/topic/TopicController.kt | 16 +- .../translation/TranslationController.kt | 225 ++++++-- .../android/domain/util/StateRetriever.kt | 70 ++- .../domain/audio/AudioPlayerControllerTest.kt | 166 +++++- .../ExplorationProgressControllerTest.kt | 131 ++++- .../HelpIndexExtensionsTest.kt | 404 ++++++++++++- .../domain/oppialogger/OppiaLoggerTest.kt | 26 +- .../analytics/AnalyticsControllerTest.kt | 362 ++++++++++-- .../ApplicationLifecycleModuleTest.kt | 4 +- .../ApplicationLifecycleObserverTest.kt | 3 +- .../domain/oppialogger/analytics/BUILD.bazel | 123 ++++ .../CpuPerformanceSnapshotterTest.kt | 3 +- .../analytics/LearnerAnalyticsLoggerTest.kt | 436 ++++++++++++-- .../oppialogger/analytics/testing/BUILD.bazel | 41 ++ .../analytics/testing/FakeLogSchedulerTest.kt | 3 +- .../loguploader/LogUploadWorkerTest.kt | 26 +- .../ProfileManagementControllerTest.kt | 285 ++++++++- ...uestionAssessmentProgressControllerTest.kt | 6 +- .../topic/StoryProgressControllerTest.kt | 3 +- .../domain/topic/TopicControllerTest.kt | 2 +- .../translation/TranslationControllerTest.kt | 455 +++++++++++++++ model/src/main/proto/BUILD.bazel | 5 +- model/src/main/proto/exploration.proto | 20 +- model/src/main/proto/oppia_logger.proto | 58 +- model/src/main/proto/profile.proto | 4 + .../assets/kdoc_validity_exemptions.textproto | 7 - scripts/assets/test_file_exemptions.textproto | 5 +- .../testing/logging/EventLogSubject.kt | 410 +++++++++++-- .../testing/profile/ProfileTestHelperTest.kt | 13 +- .../util/logging/EventBundleCreator.kt | 275 +++++---- ...entTypeToHumanReadableNameConverterImpl.kt | 7 +- ...entTypeToHumanReadableNameConverterImpl.kt | 7 +- .../android/util/parser/html/BUILD.bazel | 14 - .../parser/html/CustomHtmlContentHandler.kt | 23 +- .../android/util/parser/html/LiTagHandler.kt | 297 +++++++--- .../parser/html/ListItemLeadingMarginSpan.kt | 152 +++-- .../android/util/parser/html/ListItemMark.kt | 10 - .../util/parser/image/UrlImageParser.kt | 69 ++- .../util/parser/svg/ScalableVectorGraphic.kt | 37 +- .../util/parser/svg/SvgPictureDrawable.kt | 48 +- utility/src/main/res/values/dimens.xml | 11 +- .../util/logging/EventBundleCreatorTest.kt | 540 +++++++++++++---- .../KenyaAlphaEventBundleCreatorTest.kt | 341 ++++++++--- 205 files changed, 10793 insertions(+), 2855 deletions(-) create mode 100644 app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ShareIdsViewModel.kt delete mode 100644 app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/ChapterSelector.kt create mode 100644 app/src/main/java/org/oppia/android/app/hintsandsolution/HintViewModel.kt create mode 100644 app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionViewModel.kt delete mode 100644 app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt rename app/src/main/res/drawable/{start_over_button_background.xml => secondary_button_background.xml} (78%) rename app/src/main/res/layout/{hints_summary.xml => hint_summary.xml} (96%) create mode 100644 app/src/main/res/layout/profile_list_share_ids_item.xml create mode 100644 domain/src/test/java/org/oppia/android/domain/oppialogger/analytics/testing/BUILD.bazel delete mode 100644 utility/src/main/java/org/oppia/android/util/parser/html/ListItemMark.kt diff --git a/app/BUILD.bazel b/app/BUILD.bazel index 0217dd131b4..008ff7a779a 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -189,6 +189,7 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/DeviceIdItemViewModel.kt", "src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileLearnerIdItemViewModel.kt", "src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileListViewModel.kt", + "src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ShareIdsViewModel.kt", "src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/SyncStatusItemViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserViewModel.kt", @@ -200,7 +201,8 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/help/thirdparty/LicenseListViewModel.kt", "src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewModel.kt", "src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListViewModel.kt", - "src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt", + "src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionViewModel.kt", + "src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt", "src/main/java/org/oppia/android/app/home/HomeViewModel.kt", "src/main/java/org/oppia/android/app/home/WelcomeViewModel.kt", "src/main/java/org/oppia/android/app/home/promotedlist/ComingSoonTopicsViewModel.kt", @@ -283,9 +285,9 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/help/HelpItems.kt", "src/main/java/org/oppia/android/app/help/thirdparty/LicenseItemViewModel.kt", "src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyItemViewModel.kt", + "src/main/java/org/oppia/android/app/hintsandsolution/HintViewModel.kt", "src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionItemViewModel.kt", "src/main/java/org/oppia/android/app/hintsandsolution/ReturnToLessonViewModel.kt", - "src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt", "src/main/java/org/oppia/android/app/home/HomeItemViewModel.kt", "src/main/java/org/oppia/android/app/home/promotedlist/ComingSoonTopicListViewModel.kt", "src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedItemViewModel.kt", @@ -874,6 +876,7 @@ TEST_DEPS = [ "//testing/src/main/java/org/oppia/android/testing/espresso:konfetti_view_matcher", "//testing/src/main/java/org/oppia/android/testing/espresso:text_input_action", "//testing/src/main/java/org/oppia/android/testing/junit:initialize_default_locale_rule", + "//testing/src/main/java/org/oppia/android/testing/logging:event_log_subject", "//testing/src/main/java/org/oppia/android/testing/math:math_equation_subject", "//testing/src/main/java/org/oppia/android/testing/math:math_expression_subject", "//testing/src/main/java/org/oppia/android/testing/mockito", diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdFragmentPresenter.kt index e75218bb232..dd008b8aaad 100644 --- a/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdFragmentPresenter.kt @@ -10,6 +10,7 @@ import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.databinding.ProfileAndDeviceIdFragmentBinding import org.oppia.android.databinding.ProfileListDeviceIdItemBinding import org.oppia.android.databinding.ProfileListLearnerIdItemBinding +import org.oppia.android.databinding.ProfileListShareIdsItemBinding import org.oppia.android.databinding.ProfileListSyncStatusItemBinding import javax.inject.Inject @@ -46,27 +47,29 @@ class ProfileAndDeviceIdFragmentPresenter @Inject constructor( is DeviceIdItemViewModel -> ProfileListItemViewType.DEVICE_ID is ProfileLearnerIdItemViewModel -> ProfileListItemViewType.LEARNER_ID is SyncStatusItemViewModel -> ProfileListItemViewType.SYNC_STATUS + is ShareIdsViewModel -> ProfileListItemViewType.SHARE_IDS else -> error("Encountered unexpected view model: $viewModel") } - } - .registerViewDataBinder( - viewType = ProfileListItemViewType.DEVICE_ID, - inflateDataBinding = ProfileListDeviceIdItemBinding::inflate, - setViewModel = ProfileListDeviceIdItemBinding::setViewModel, - transformViewModel = { it as DeviceIdItemViewModel } - ) - .registerViewDataBinder( - viewType = ProfileListItemViewType.LEARNER_ID, - inflateDataBinding = ProfileListLearnerIdItemBinding::inflate, - setViewModel = ProfileListLearnerIdItemBinding::setViewModel, - transformViewModel = { it as ProfileLearnerIdItemViewModel } - ) - .registerViewDataBinder( - viewType = ProfileListItemViewType.SYNC_STATUS, - inflateDataBinding = ProfileListSyncStatusItemBinding::inflate, - setViewModel = ProfileListSyncStatusItemBinding::setViewModel, - transformViewModel = { it as SyncStatusItemViewModel } - ) - .build() + }.registerViewDataBinder( + viewType = ProfileListItemViewType.DEVICE_ID, + inflateDataBinding = ProfileListDeviceIdItemBinding::inflate, + setViewModel = ProfileListDeviceIdItemBinding::setViewModel, + transformViewModel = { it as DeviceIdItemViewModel } + ).registerViewDataBinder( + viewType = ProfileListItemViewType.LEARNER_ID, + inflateDataBinding = ProfileListLearnerIdItemBinding::inflate, + setViewModel = ProfileListLearnerIdItemBinding::setViewModel, + transformViewModel = { it as ProfileLearnerIdItemViewModel } + ).registerViewDataBinder( + viewType = ProfileListItemViewType.SYNC_STATUS, + inflateDataBinding = ProfileListSyncStatusItemBinding::inflate, + setViewModel = ProfileListSyncStatusItemBinding::setViewModel, + transformViewModel = { it as SyncStatusItemViewModel } + ).registerViewDataBinder( + viewType = ProfileListItemViewType.SHARE_IDS, + inflateDataBinding = ProfileListShareIdsItemBinding::inflate, + setViewModel = ProfileListShareIdsItemBinding::setViewModel, + transformViewModel = { it as ShareIdsViewModel } + ).build() } } diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileListViewModel.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileListViewModel.kt index 895cb20d696..ff3778b85d8 100644 --- a/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileListViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileListViewModel.kt @@ -23,20 +23,18 @@ class ProfileListViewModel private constructor( private val oppiaLogger: OppiaLogger, private val deviceIdItemViewModelFactory: DeviceIdItemViewModel.Factory, private val syncStatusItemViewModelFactory: SyncStatusItemViewModel.Factory, - private val profileLearnerIdItemViewModelFactory: ProfileLearnerIdItemViewModel.Factory + private val profileLearnerIdItemViewModelFactory: ProfileLearnerIdItemViewModel.Factory, + private val shareIdsViewModelFactory: ShareIdsViewModel.Factory ) : ObservableViewModel() { /** The list of [ProfileListItemViewModel] to display. */ val profileModels: LiveData> by lazy { - Transformations.map( - profileManagementController.getProfiles().toLiveData(), ::processProfiles - ) + Transformations.map(profileManagementController.getProfiles().toLiveData(), ::processProfiles) } private fun processProfiles( profilesResult: AsyncResult> ): List { val deviceIdViewModel = deviceIdItemViewModelFactory.create() - val syncStatusViewModel = syncStatusItemViewModelFactory.create() val learnerIdModels = when (profilesResult) { is AsyncResult.Pending -> listOf() @@ -56,7 +54,10 @@ class ProfileListViewModel private constructor( } } - return listOf(deviceIdViewModel) + learnerIdModels + listOf(syncStatusViewModel) + val idModels = listOf(deviceIdViewModel) + learnerIdModels + val shareIdsViewModel = shareIdsViewModelFactory.create(idModels) + val syncStatusViewModel = syncStatusItemViewModelFactory.create() + return idModels + listOf(syncStatusViewModel, shareIdsViewModel) } /** @@ -80,7 +81,10 @@ class ProfileListViewModel private constructor( LEARNER_ID, /** Corresponds to [SyncStatusItemViewModel]. */ - SYNC_STATUS + SYNC_STATUS, + + /** Corresponds to [ShareIdsViewModel]. */ + SHARE_IDS } /** Factory to create new [ProfileListViewModel]s. */ @@ -89,7 +93,8 @@ class ProfileListViewModel private constructor( private val oppiaLogger: OppiaLogger, private val deviceIdItemViewModelFactory: DeviceIdItemViewModel.Factory, private val syncStatusItemViewModelFactory: SyncStatusItemViewModel.Factory, - private val profileLearnerIdItemViewModelFactory: ProfileLearnerIdItemViewModel.Factory + private val profileLearnerIdItemViewModelFactory: ProfileLearnerIdItemViewModel.Factory, + private val shareIdsViewModelFactory: ShareIdsViewModel.Factory ) { /** Returns a new [ProfileListViewModel]. */ fun create(): ProfileListViewModel { @@ -98,7 +103,8 @@ class ProfileListViewModel private constructor( oppiaLogger, deviceIdItemViewModelFactory, syncStatusItemViewModelFactory, - profileLearnerIdItemViewModelFactory + profileLearnerIdItemViewModelFactory, + shareIdsViewModelFactory ) } } diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ShareIdsViewModel.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ShareIdsViewModel.kt new file mode 100644 index 00000000000..435315c705c --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ShareIdsViewModel.kt @@ -0,0 +1,58 @@ +package org.oppia.android.app.administratorcontrols.learneranalytics + +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import org.oppia.android.app.administratorcontrols.learneranalytics.ProfileListViewModel.ProfileListItemViewModel +import org.oppia.android.domain.oppialogger.OppiaLogger +import javax.inject.Inject + +/** + * [ProfileListItemViewModel] that represents the portion of the learner analytics admin page which + * provides a button to the user that allows them to share the device's installation ID, along with + * profile IDs, to an external app for record keeping. + */ +@SuppressLint("StaticFieldLeak") // False positive (Android doesn't manage this model's lifecycle). +class ShareIdsViewModel private constructor( + private val oppiaLogger: OppiaLogger, + private val activity: AppCompatActivity, + private val viewModels: List +) : ProfileListItemViewModel(ProfileListViewModel.ProfileListItemViewType.SHARE_IDS) { + /** Indicates the user wants to share learner & the device IDs with another app. */ + fun onShareIdsButtonClicked() { + // Reference: https://developer.android.com/guide/components/intents-common#Email from + // https://stackoverflow.com/a/15022153/3689782. + val sharedText = viewModels.mapNotNull { viewModel -> + when (viewModel) { + is DeviceIdItemViewModel -> "Oppia app installation ID: ${viewModel.deviceId.value}" + is ProfileLearnerIdItemViewModel -> { + val profile = viewModel.profile + "- Profile name: ${profile.name}, learner ID: ${profile.learnerId}" + } + else -> null + } + }.joinToString(separator = "\n") + try { + activity.startActivity( + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(Intent.EXTRA_TEXT, sharedText) + } + ) + } catch (e: ActivityNotFoundException) { + oppiaLogger.e("ProfileAndDeviceIdViewModel", "No activity found to receive shared IDs.", e) + } + } + + /** Factory for creating new [ShareIdsViewModel]s. */ + class Factory @Inject constructor( + private val oppiaLogger: OppiaLogger, + private val activity: AppCompatActivity + ) { + /** Returns a new [ShareIdsViewModel]. */ + fun create(viewModels: List): ShareIdsViewModel = + ShareIdsViewModel(oppiaLogger, activity, viewModels) + } +} diff --git a/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt index 2d6b82eb17b..bf8f287edb7 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt @@ -47,8 +47,9 @@ class DeveloperOptionsActivity : override fun routeToMarkChaptersCompleted() { startActivity( - MarkChaptersCompletedActivity - .createMarkChaptersCompletedIntent(this, internalProfileId) + MarkChaptersCompletedActivity.createMarkChaptersCompletedIntent( + context = this, internalProfileId, showConfirmationNotice = false + ) ) } @@ -86,10 +87,6 @@ class DeveloperOptionsActivity : decorateWithScreenName(DEVELOPER_OPTIONS_ACTIVITY) } } - - fun getIntentKey(): String { - return NAVIGATION_PROFILE_ID_ARGUMENT_KEY - } } override fun forceCrash() { diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/ChapterSelector.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/ChapterSelector.kt deleted file mode 100644 index b5214304acb..00000000000 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/ChapterSelector.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.oppia.android.app.devoptions.markchapterscompleted - -/** Interface to update the selectedChapterList in [MarkChaptersCompletedFragmentPresenter]. */ -interface ChapterSelector { - /** This chapter will get added to selectedTopicList in [MarkChaptersCompletedFragmentPresenter]. */ - fun chapterSelected(chapterIndex: Int, nextStoryIndex: Int, explorationId: String) - - /** - * Chapters from 'chapterIndex' until 'nextStoryIndex' will get removed from selectedTopicList in - * [MarkChaptersCompletedFragmentPresenter]. - */ - fun chapterUnselected(chapterIndex: Int, nextStoryIndex: Int) -} diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt index 27997cc5bb0..e8d24483432 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt @@ -21,13 +21,13 @@ class MarkChaptersCompletedActivity : InjectableAppCompatActivity() { @Inject lateinit var resourceHandler: AppLanguageResourceHandler - private var internalProfileId = -1 - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) - internalProfileId = intent.getIntExtra(PROFILE_ID_EXTRA_KEY, -1) - markChaptersCompletedActivityPresenter.handleOnCreate(internalProfileId) + val internalProfileId = intent.getIntExtra(PROFILE_ID_EXTRA_KEY, /* defaultValue= */ -1) + val showConfirmationNotice = + intent.getBooleanExtra(SHOW_CONFIRMATION_NOTICE_EXTRA_KEY, /* defaultValue= */ false) + markChaptersCompletedActivityPresenter.handleOnCreate(internalProfileId, showConfirmationNotice) title = resourceHandler.getStringInLocale(R.string.mark_chapters_completed_activity_title) } @@ -39,11 +39,18 @@ class MarkChaptersCompletedActivity : InjectableAppCompatActivity() { } companion object { - const val PROFILE_ID_EXTRA_KEY = "MarkChaptersCompletedActivity.profile_id" - - fun createMarkChaptersCompletedIntent(context: Context, internalProfileId: Int): Intent { + private const val PROFILE_ID_EXTRA_KEY = "MarkChaptersCompletedActivity.profile_id" + private const val SHOW_CONFIRMATION_NOTICE_EXTRA_KEY = + "MarkChaptersCompletedActivity.show_confirmation_notice" + + fun createMarkChaptersCompletedIntent( + context: Context, + internalProfileId: Int, + showConfirmationNotice: Boolean + ): Intent { return Intent(context, MarkChaptersCompletedActivity::class.java).apply { putExtra(PROFILE_ID_EXTRA_KEY, internalProfileId) + putExtra(SHOW_CONFIRMATION_NOTICE_EXTRA_KEY, showConfirmationNotice) decorateWithScreenName(MARK_CHAPTERS_COMPLETED_ACTIVITY) } } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivityPresenter.kt index a2714817cb2..6605456b86e 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivityPresenter.kt @@ -11,14 +11,14 @@ class MarkChaptersCompletedActivityPresenter @Inject constructor( private val activity: AppCompatActivity ) { - fun handleOnCreate(internalProfileId: Int) { + fun handleOnCreate(internalProfileId: Int, showConfirmationNotice: Boolean) { activity.supportActionBar?.setDisplayHomeAsUpEnabled(true) activity.supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back_white_24dp) activity.setContentView(R.layout.mark_chapters_completed_activity) if (getMarkChaptersCompletedFragment() == null) { - val markChaptersCompletedFragment = MarkChaptersCompletedFragment - .newInstance(internalProfileId) + val markChaptersCompletedFragment = + MarkChaptersCompletedFragment.newInstance(internalProfileId, showConfirmationNotice) activity.supportFragmentManager.beginTransaction().add( R.id.mark_chapters_completed_container, markChaptersCompletedFragment diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragment.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragment.kt index e36bfbdd844..9ea2d5af880 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragment.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragment.kt @@ -15,17 +15,23 @@ class MarkChaptersCompletedFragment : InjectableFragment() { lateinit var markChaptersCompletedFragmentPresenter: MarkChaptersCompletedFragmentPresenter companion object { - internal const val PROFILE_ID_ARGUMENT_KEY = - "MarkChaptersCompletedFragment.profile_id" - + private const val PROFILE_ID_ARGUMENT_KEY = "MarkChaptersCompletedFragment.profile_id" + private const val SHOW_CONFIRMATION_NOTICE_ARGUMENT_KEY = + "MarkChaptersCompletedFragment.show_confirmation_notice" private const val EXPLORATION_ID_LIST_ARGUMENT_KEY = "MarkChaptersCompletedFragment.exploration_id_list" + private const val EXPLORATION_TITLE_LIST_ARGUMENT_KEY = + "MarkChaptersCompletedFragment.exploration_title_list" /** Returns a new [MarkChaptersCompletedFragment]. */ - fun newInstance(internalProfileId: Int): MarkChaptersCompletedFragment { + fun newInstance( + internalProfileId: Int, + showConfirmationNotice: Boolean + ): MarkChaptersCompletedFragment { val markChaptersCompletedFragment = MarkChaptersCompletedFragment() val args = Bundle() args.putInt(PROFILE_ID_ARGUMENT_KEY, internalProfileId) + args.putBoolean(SHOW_CONFIRMATION_NOTICE_ARGUMENT_KEY, showConfirmationNotice) markChaptersCompletedFragment.arguments = args return markChaptersCompletedFragment } @@ -43,18 +49,16 @@ class MarkChaptersCompletedFragment : InjectableFragment() { ): View? { val args = checkNotNull(arguments) { "Expected arguments to be passed to MarkChaptersCompletedFragment" } - val internalProfileId = args - .getInt(PROFILE_ID_ARGUMENT_KEY, -1) - var selectedExplorationIdList = ArrayList() - if (savedInstanceState != null) { - selectedExplorationIdList = - savedInstanceState.getStringArrayList(EXPLORATION_ID_LIST_ARGUMENT_KEY)!! - } + val internalProfileId = args.getInt(PROFILE_ID_ARGUMENT_KEY, /* defaultValue= */ -1) + val showConfirmationNotice = + args.getBoolean(SHOW_CONFIRMATION_NOTICE_ARGUMENT_KEY, /* defaultValue= */ false) return markChaptersCompletedFragmentPresenter.handleCreateView( inflater, container, internalProfileId, - selectedExplorationIdList + showConfirmationNotice, + savedInstanceState?.getStringArrayList(EXPLORATION_ID_LIST_ARGUMENT_KEY) ?: listOf(), + savedInstanceState?.getStringArrayList(EXPLORATION_TITLE_LIST_ARGUMENT_KEY) ?: listOf() ) } @@ -62,7 +66,11 @@ class MarkChaptersCompletedFragment : InjectableFragment() { super.onSaveInstanceState(outState) outState.putStringArrayList( EXPLORATION_ID_LIST_ARGUMENT_KEY, - markChaptersCompletedFragmentPresenter.selectedExplorationIdList + markChaptersCompletedFragmentPresenter.serializableSelectedExplorationIds + ) + outState.putStringArrayList( + EXPLORATION_TITLE_LIST_ARGUMENT_KEY, + markChaptersCompletedFragmentPresenter.serializableSelectedExplorationTitles ) } } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragmentPresenter.kt index 6c6e46c8266..98a5d8f35fc 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragmentPresenter.kt @@ -3,13 +3,16 @@ package org.oppia.android.app.devoptions.markchapterscompleted import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.ProfileId import org.oppia.android.app.recyclerview.BindableAdapter +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.MarkChaptersCompletedChapterSummaryViewBinding import org.oppia.android.databinding.MarkChaptersCompletedFragmentBinding import org.oppia.android.databinding.MarkChaptersCompletedStorySummaryViewBinding @@ -23,19 +26,29 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( private val fragment: Fragment, private val viewModel: MarkChaptersCompletedViewModel, private val modifyLessonProgressController: ModifyLessonProgressController, - private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory -) : ChapterSelector { + private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory, + private val resourceHandler: AppLanguageResourceHandler +) { private lateinit var binding: MarkChaptersCompletedFragmentBinding private lateinit var linearLayoutManager: LinearLayoutManager private lateinit var bindingAdapter: BindableAdapter - lateinit var selectedExplorationIdList: ArrayList private lateinit var profileId: ProfileId + private lateinit var alertDialog: AlertDialog + private val selectedExplorationIds = mutableListOf() + private val selectedExplorationTitles = mutableListOf() + + val serializableSelectedExplorationIds: ArrayList + get() = ArrayList(selectedExplorationIds) + val serializableSelectedExplorationTitles: ArrayList + get() = ArrayList(selectedExplorationTitles) fun handleCreateView( inflater: LayoutInflater, container: ViewGroup?, internalProfileId: Int, - selectedExplorationIdList: ArrayList + showConfirmationNotice: Boolean, + selectedExplorationIds: List, + selectedExplorationTitles: List ): View? { binding = MarkChaptersCompletedFragmentBinding.inflate( inflater, @@ -52,7 +65,8 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( this.viewModel = this@MarkChaptersCompletedFragmentPresenter.viewModel } - this.selectedExplorationIdList = selectedExplorationIdList + this.selectedExplorationIds += selectedExplorationIds + this.selectedExplorationTitles += selectedExplorationTitles profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build() viewModel.setProfileId(profileId) @@ -74,7 +88,8 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( chapterSelected( viewModel.chapterIndex, viewModel.nextStoryIndex, - viewModel.chapterSummary.explorationId + viewModel.chapterSummary.explorationId, + viewModel.chapterTitle ) } } @@ -91,11 +106,9 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( } binding.markChaptersCompletedMarkCompletedTextView.setOnClickListener { - modifyLessonProgressController.markMultipleChaptersCompleted( - profileId = profileId, - chapterMap = viewModel.getChapterMap().filterKeys { selectedExplorationIdList.contains(it) } - ) - activity.finish() + if (showConfirmationNotice && this.selectedExplorationIds.isNotEmpty()) { + showConfirmationDialog() + } else markChaptersAsCompleted() } return binding.root @@ -140,21 +153,19 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( binding.isChapterChecked = true binding.isChapterCheckboxEnabled = false } else { - binding.isChapterChecked = - selectedExplorationIdList.contains(model.chapterSummary.explorationId) + binding.isChapterChecked = model.chapterSummary.explorationId in selectedExplorationIds binding.isChapterCheckboxEnabled = !model.chapterSummary.hasMissingPrerequisiteChapter() || model.chapterSummary.hasMissingPrerequisiteChapter() && - selectedExplorationIdList.contains( - model.chapterSummary.missingPrerequisiteChapter.explorationId - ) + model.chapterSummary.missingPrerequisiteChapter.explorationId in selectedExplorationIds binding.markChaptersCompletedChapterCheckBox.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { chapterSelected( model.chapterIndex, model.nextStoryIndex, - model.chapterSummary.explorationId + model.chapterSummary.explorationId, + model.chapterTitle ) } else { chapterUnselected(model.chapterIndex, model.nextStoryIndex) @@ -163,39 +174,34 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( } } - override fun chapterSelected(chapterIndex: Int, nextStoryIndex: Int, explorationId: String) { - if (!selectedExplorationIdList.contains(explorationId)) { - selectedExplorationIdList.add(explorationId) + private fun chapterSelected(chapterIdx: Int, nextStoryIdx: Int, expId: String, expTitle: String) { + if (expId !in selectedExplorationIds) { + selectedExplorationIds += expId + selectedExplorationTitles += expTitle } - if (selectedExplorationIdList.size == - viewModel.getItemList().count { - it is ChapterSummaryViewModel && !it.checkIfChapterIsCompleted() - } - ) { + if (selectedExplorationIds.size == viewModel.getItemList().countIncompleteChapters()) { binding.isAllChecked = true } if (!binding.markChaptersCompletedRecyclerView.isComputingLayout && binding.markChaptersCompletedRecyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE ) { - bindingAdapter.notifyItemChanged(chapterIndex) - if (chapterIndex + 1 < nextStoryIndex) - bindingAdapter.notifyItemChanged(chapterIndex + 1) + bindingAdapter.notifyItemChanged(chapterIdx) + if (chapterIdx + 1 < nextStoryIdx) + bindingAdapter.notifyItemChanged(chapterIdx + 1) } } - override fun chapterUnselected(chapterIndex: Int, nextStoryIndex: Int) { + private fun chapterUnselected(chapterIndex: Int, nextStoryIndex: Int) { for (index in chapterIndex until nextStoryIndex) { val explorationId = (viewModel.getItemList()[index] as ChapterSummaryViewModel).chapterSummary.explorationId - if (selectedExplorationIdList.contains(explorationId)) { - selectedExplorationIdList.remove(explorationId) + val expIndex = selectedExplorationIds.indexOf(explorationId) + if (expIndex != -1) { + selectedExplorationIds.removeAt(expIndex) + selectedExplorationTitles.removeAt(expIndex) } } - if (selectedExplorationIdList.size != - viewModel.getItemList().count { - it is ChapterSummaryViewModel && !it.checkIfChapterIsCompleted() - } - ) { + if (selectedExplorationIds.size != viewModel.getItemList().countIncompleteChapters()) { binding.isAllChecked = false } if (!binding.markChaptersCompletedRecyclerView.isComputingLayout && @@ -208,8 +214,54 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( } } + private fun showConfirmationDialog() { + alertDialog = AlertDialog.Builder(activity, R.style.OppiaAlertDialogTheme).apply { + setTitle(R.string.mark_chapters_completed_confirm_setting_dialog_title) + setMessage( + resourceHandler.getStringInLocaleWithWrapping( + R.string.mark_chapters_completed_confirm_setting_dialog_message, + selectedExplorationTitles.joinToReadableString() + ) + ) + setNegativeButton( + R.string.mark_chapters_completed_confirm_setting_dialog_cancel_button_text + ) { dialog, _ -> dialog.dismiss() } + setPositiveButton( + R.string.mark_chapters_completed_confirm_setting_dialog_confirm_button_text + ) { dialog, _ -> + dialog.dismiss() + markChaptersAsCompleted() + } + }.create().also { + it.setCanceledOnTouchOutside(true) + it.show() + } + } + + private fun markChaptersAsCompleted() { + modifyLessonProgressController.markMultipleChaptersCompleted( + profileId = profileId, + chapterMap = viewModel.getChapterMap().filterKeys { it in selectedExplorationIds } + ) + activity.finish() + } + private enum class ViewType { VIEW_TYPE_STORY, VIEW_TYPE_CHAPTER } + + private companion object { + private fun List.joinToReadableString(): String { + return when (size) { + 0 -> "" + 1 -> single() + 2 -> "${this[0]} and ${this[1]}" + else -> "${asSequence().take(size - 1).joinToString()}, and ${last()}" + } + } + + private fun List.countIncompleteChapters() = + filterIsInstance().count { !it.checkIfChapterIsCompleted() } + } } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/testing/MarkChaptersCompletedTestActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/testing/MarkChaptersCompletedTestActivity.kt index 66bb84cb5e0..2cd8e78e37d 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/testing/MarkChaptersCompletedTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/testing/MarkChaptersCompletedTestActivity.kt @@ -10,19 +10,18 @@ import org.oppia.android.app.devoptions.markchapterscompleted.MarkChaptersComple /** The activity for testing [MarkChaptersCompletedFragment]. */ class MarkChaptersCompletedTestActivity : InjectableAppCompatActivity() { - - private var internalProfileId = -1 - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back_white_24dp) setContentView(R.layout.mark_chapters_completed_activity) - internalProfileId = intent.getIntExtra(PROFILE_ID_EXTRA_KEY, -1) + val internalProfileId = intent.getIntExtra(PROFILE_ID_EXTRA_KEY, /* default= */ -1) + val showConfirmationNotice = + intent.getBooleanExtra(SHOW_CONFIRMATION_NOTICE_EXTRA_KEY, /* default= */ false) if (getMarkChaptersCompletedFragment() == null) { - val markChaptersCompletedFragment = MarkChaptersCompletedFragment - .newInstance(internalProfileId) + val markChaptersCompletedFragment = + MarkChaptersCompletedFragment.newInstance(internalProfileId, showConfirmationNotice) supportFragmentManager.beginTransaction().add( R.id.mark_chapters_completed_container, markChaptersCompletedFragment @@ -36,12 +35,19 @@ class MarkChaptersCompletedTestActivity : InjectableAppCompatActivity() { } companion object { - const val PROFILE_ID_EXTRA_KEY = "MarkChaptersCompletedTestActivity.profile_id" + private const val PROFILE_ID_EXTRA_KEY = "MarkChaptersCompletedTestActivity.profile_id" + private const val SHOW_CONFIRMATION_NOTICE_EXTRA_KEY = + "MarkChaptersCompletedTestActivity.show_confirmation_notice" /** Returns an [Intent] for [MarkChaptersCompletedTestActivity]. */ - fun createMarkChaptersCompletedTestIntent(context: Context, internalProfileId: Int): Intent { + fun createMarkChaptersCompletedTestIntent( + context: Context, + internalProfileId: Int, + showConfirmationNotice: Boolean + ): Intent { val intent = Intent(context, MarkChaptersCompletedTestActivity::class.java) intent.putExtra(PROFILE_ID_EXTRA_KEY, internalProfileId) + intent.putExtra(SHOW_CONFIRMATION_NOTICE_EXTRA_KEY, showConfirmationNotice) return intent } } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/testing/DeveloperOptionsTestActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/testing/DeveloperOptionsTestActivity.kt index 046bb5ee117..b9dae33e7d8 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/testing/DeveloperOptionsTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/testing/DeveloperOptionsTestActivity.kt @@ -50,8 +50,9 @@ class DeveloperOptionsTestActivity : override fun routeToMarkChaptersCompleted() { startActivity( - MarkChaptersCompletedActivity - .createMarkChaptersCompletedIntent(this, internalProfileId) + MarkChaptersCompletedActivity.createMarkChaptersCompletedIntent( + context = this, internalProfileId, showConfirmationNotice = false + ) ) } diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintViewModel.kt new file mode 100644 index 00000000000..57b4b8a2f47 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintViewModel.kt @@ -0,0 +1,29 @@ +package org.oppia.android.app.hintsandsolution + +import androidx.databinding.ObservableBoolean +import org.oppia.android.util.parser.html.CustomHtmlContentHandler + +/** + * [HintsAndSolutionItemViewModel] that represents a single hint that can be shown to the user. + * + * @property title the title of this hint, relative to others (this is generated by the app) + * @property hintSummary the core hint text (which may contain HTML) to show the user + * @property isHintRevealed whether the hint is currently expanded and viewable + */ +class HintViewModel( + val title: String, + val hintSummary: String, + val isHintRevealed: ObservableBoolean +) : HintsAndSolutionItemViewModel() { + /** + * A screenreader-friendly version of [hintSummary] that should be used for readout, in place of + * the original summary. + */ + val hintContentDescription: String by lazy { + CustomHtmlContentHandler.fromHtml( + hintSummary, + imageRetriever = null, + customTagHandlers = mapOf() + ).toString() + } +} diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt index 0552263f5a4..532b1dac6b4 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt @@ -9,6 +9,7 @@ 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.HelpIndex +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.State import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.util.extensions.getProto @@ -47,6 +48,8 @@ class HintsAndSolutionDialogFragment : internal const val HELP_INDEX_KEY = "HintsAndSolutionDialogFragment.help_index" internal const val WRITTEN_TRANSLATION_CONTEXT_KEY = "HintsAndSolutionDialogFragment.written_translation_context" + internal const val PROFILE_ID_KEY = + "HintsAndSolutionDialogFragment.profile_id" /** * Creates a new instance of a DialogFragment to display hints and solution @@ -57,13 +60,15 @@ class HintsAndSolutionDialogFragment : * @param helpIndex the [HelpIndex] corresponding to the current hints/solution configuration * @param writtenTranslationContext the [WrittenTranslationContext] needed to translate the * hints/solution + * @param profileId the ID of the profile viewing the hint/solution * @return [HintsAndSolutionDialogFragment]: DialogFragment */ fun newInstance( id: String, state: State, helpIndex: HelpIndex, - writtenTranslationContext: WrittenTranslationContext + writtenTranslationContext: WrittenTranslationContext, + profileId: ProfileId ): HintsAndSolutionDialogFragment { return HintsAndSolutionDialogFragment().apply { arguments = Bundle().apply { @@ -71,6 +76,7 @@ class HintsAndSolutionDialogFragment : putProto(STATE_KEY, state) putProto(HELP_INDEX_KEY, helpIndex) putProto(WRITTEN_TRANSLATION_CONTEXT_KEY, writtenTranslationContext) + putProto(PROFILE_ID_KEY, profileId) } } } @@ -114,6 +120,7 @@ class HintsAndSolutionDialogFragment : val helpIndex = args.getProto(HELP_INDEX_KEY, HelpIndex.getDefaultInstance()) val writtenTranslationContext = args.getProto(WRITTEN_TRANSLATION_CONTEXT_KEY, WrittenTranslationContext.getDefaultInstance()) + val profileId = args.getProto(PROFILE_ID_KEY, ProfileId.getDefaultInstance()) return hintsAndSolutionDialogFragmentPresenter.handleCreateView( inflater, @@ -127,7 +134,8 @@ class HintsAndSolutionDialogFragment : index, isHintRevealed, solutionIndex, - isSolutionRevealed + isSolutionRevealed, + profileId ) } diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt index d0518034c59..c1bf05fb212 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt @@ -7,17 +7,14 @@ import androidx.fragment.app.Fragment import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.HelpIndex -import org.oppia.android.app.model.HelpIndex.IndexTypeCase.EVERYTHING_REVEALED -import org.oppia.android.app.model.HelpIndex.IndexTypeCase.LATEST_REVEALED_HINT_INDEX -import org.oppia.android.app.model.HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_INDEX -import org.oppia.android.app.model.HelpIndex.IndexTypeCase.SHOW_SOLUTION +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.State import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.recyclerview.BindableAdapter +import org.oppia.android.app.topic.conceptcard.ConceptCardFragment import org.oppia.android.app.translation.AppLanguageResourceHandler -import org.oppia.android.app.viewmodel.ViewModelProvider +import org.oppia.android.databinding.HintSummaryBinding import org.oppia.android.databinding.HintsAndSolutionFragmentBinding -import org.oppia.android.databinding.HintsSummaryBinding import org.oppia.android.databinding.ReturnToLessonButtonItemBinding import org.oppia.android.databinding.SolutionSummaryBinding import org.oppia.android.util.accessibility.AccessibilityService @@ -32,32 +29,30 @@ const val TAG_REVEAL_SOLUTION_DIALOG = "REVEAL_SOLUTION_DIALOG" @FragmentScope class HintsAndSolutionDialogFragmentPresenter @Inject constructor( private val fragment: Fragment, - private val viewModelProvider: ViewModelProvider, private val htmlParserFactory: HtmlParser.Factory, @DefaultResourceBucketName private val resourceBucketName: String, @ExplorationHtmlParserEntityType private val entityType: String, private val resourceHandler: AppLanguageResourceHandler, - private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory -) { + private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory, + private val hintsAndSolutionViewModelFactory: HintsAndSolutionViewModel.Factory +) : HtmlParser.CustomOppiaTagActionListener { @Inject lateinit var accessibilityService: AccessibilityService + private var index: Int? = null - private var expandedItemsList = ArrayList() + private val expandedItemIndexes = mutableListOf() private var isHintRevealed: Boolean? = null private var solutionIndex: Int? = null private var isSolutionRevealed: Boolean? = null private lateinit var expandedHintListIndexListener: ExpandedHintListIndexListener - private lateinit var binding: HintsAndSolutionFragmentBinding private lateinit var state: State private lateinit var helpIndex: HelpIndex private lateinit var writtenTranslationContext: WrittenTranslationContext - private lateinit var itemList: List + private lateinit var profileId: ProfileId private lateinit var bindingAdapter: BindableAdapter - - val viewModel by lazy { - getHintsAndSolutionViewModel() - } + private lateinit var explorationId: String + private lateinit var viewModel: HintsAndSolutionViewModel /** * Sets up data binding and toolbar. @@ -69,22 +64,32 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( state: State, helpIndex: HelpIndex, writtenTranslationContext: WrittenTranslationContext, - id: String?, + explorationId: String, expandedItemsList: ArrayList?, expandedHintListIndexListener: ExpandedHintListIndexListener, index: Int?, isHintRevealed: Boolean?, solutionIndex: Int?, - isSolutionRevealed: Boolean? + isSolutionRevealed: Boolean?, + profileId: ProfileId ): View { - binding = - HintsAndSolutionFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) - this.expandedItemsList = expandedItemsList ?: ArrayList() + expandedItemIndexes += expandedItemsList ?: listOf() this.expandedHintListIndexListener = expandedHintListIndexListener this.index = index this.isHintRevealed = isHintRevealed this.solutionIndex = solutionIndex this.isSolutionRevealed = isSolutionRevealed + this.state = state + this.helpIndex = helpIndex + this.writtenTranslationContext = writtenTranslationContext + this.profileId = profileId + this.explorationId = explorationId + + // Check if hints are available for this state. + viewModel = hintsAndSolutionViewModelFactory.create(state, helpIndex, writtenTranslationContext) + + val binding = + HintsAndSolutionFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) binding.hintsAndSolutionToolbar.setNavigationIcon(R.drawable.ic_close_white_24dp) binding.hintsAndSolutionToolbar.setNavigationContentDescription( R.string.hints_andSolution_close_icon_description @@ -97,66 +102,14 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( it.lifecycleOwner = fragment } - this.state = state - this.helpIndex = helpIndex - this.writtenTranslationContext = writtenTranslationContext - - val newAvailableHintIndex = computeNewAvailableHintIndex(helpIndex) - viewModel.newAvailableHintIndex.set(newAvailableHintIndex) - viewModel.allHintsExhausted.set(computeWhetherAllHintsAreExhausted(helpIndex)) - viewModel.explorationId.set(id) - - loadHintsAndSolution(state) - - return binding.root - } - - private fun computeNewAvailableHintIndex(helpIndex: HelpIndex): Int { - return when (helpIndex.indexTypeCase) { - NEXT_AVAILABLE_HINT_INDEX -> helpIndex.nextAvailableHintIndex - LATEST_REVEALED_HINT_INDEX -> helpIndex.latestRevealedHintIndex - SHOW_SOLUTION, EVERYTHING_REVEALED -> { - // 1 is subtracted from the hint count because hints are indexed from 0. - state.interaction.hintCount - 1 - } - else -> - throw IllegalStateException( - "Encountered invalid type for showing hints: ${helpIndex.indexTypeCase}" - ) - } - } - - private fun computeWhetherAllHintsAreExhausted(helpIndex: HelpIndex): Boolean { - return when (helpIndex.indexTypeCase) { - NEXT_AVAILABLE_HINT_INDEX, LATEST_REVEALED_HINT_INDEX -> false - SHOW_SOLUTION, EVERYTHING_REVEALED -> true - else -> - throw IllegalStateException( - "Encountered invalid type for showing hints: ${helpIndex.indexTypeCase}" - ) - } - } - - private fun loadHintsAndSolution(state: State) { - // Check if hints are available for this state. - if (state.interaction.hintList.isNotEmpty()) { - viewModel.initialize( - helpIndex, state.interaction.hintList, state.interaction.solution, writtenTranslationContext - ) - - itemList = viewModel.processHintList() - + if (state.interaction.hintList.isNotEmpty() || state.interaction.hasSolution()) { binding.hintsAndSolutionRecyclerView.apply { bindingAdapter = createRecyclerViewAdapter() adapter = bindingAdapter } - if (viewModel.newAvailableHintIndex.get() != -1) { - handleNewAvailableHint(viewModel.newAvailableHintIndex.get()) - } - if (viewModel.allHintsExhausted.get()!!) { - handleAllHintsExhausted(viewModel.allHintsExhausted.get()!!) - } } + + return binding.root } private enum class ViewType { @@ -168,16 +121,16 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( private fun createRecyclerViewAdapter(): BindableAdapter { return multiTypeBuilderFactory.create { viewModel -> when (viewModel) { - is HintsViewModel -> ViewType.VIEW_TYPE_HINT_ITEM + is HintViewModel -> ViewType.VIEW_TYPE_HINT_ITEM is SolutionViewModel -> ViewType.VIEW_TYPE_SOLUTION_ITEM is ReturnToLessonViewModel -> ViewType.VIEW_TYPE_RETURN_TO_LESSON_ITEM else -> throw IllegalArgumentException("Encountered unexpected view model: $viewModel") } }.registerViewDataBinder( viewType = ViewType.VIEW_TYPE_HINT_ITEM, - inflateDataBinding = HintsSummaryBinding::inflate, - setViewModel = this::bindHintsViewModel, - transformViewModel = { it as HintsViewModel } + inflateDataBinding = HintSummaryBinding::inflate, + setViewModel = this::bindHintViewModel, + transformViewModel = { it as HintViewModel } ).registerViewDataBinder( viewType = ViewType.VIEW_TYPE_SOLUTION_ITEM, inflateDataBinding = SolutionSummaryBinding::inflate, @@ -191,20 +144,17 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( ).build() } - private fun bindHintsViewModel( - binding: HintsSummaryBinding, - hintsViewModel: HintsViewModel - ) { - binding.viewModel = hintsViewModel + private fun bindHintViewModel(binding: HintSummaryBinding, hintViewModel: HintViewModel) { + binding.viewModel = hintViewModel - val position: Int = itemList.indexOf(hintsViewModel) + val position: Int = viewModel.itemList.indexOf(hintViewModel) - binding.isListExpanded = expandedItemsList.contains(position) + binding.isListExpanded = position in expandedItemIndexes index?.let { index -> isHintRevealed?.let { isHintRevealed -> if (index == position && isHintRevealed) { - hintsViewModel.isHintRevealed.set(true) + hintViewModel.isHintRevealed.set(true) } } } @@ -213,32 +163,31 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( htmlParserFactory.create( resourceBucketName, entityType, - hintsViewModel.explorationId.get()!!, - /* imageCenterAlign= */ true, + explorationId, + customOppiaTagActionListener = this, + imageCenterAlign = true, displayLocale = resourceHandler.getDisplayLocale() ).parseOppiaHtml( - hintsViewModel.hintsAndSolutionSummary.get()!!, - binding.hintsAndSolutionSummary + hintViewModel.hintSummary, + binding.hintsAndSolutionSummary, + supportsLinks = true, + supportsConceptCards = true ) - if (hintsViewModel.hintCanBeRevealed.get()!!) { - binding.root.visibility = View.VISIBLE - binding.revealHintButton.setOnClickListener { - hintsViewModel.isHintRevealed.set(true) - expandedHintListIndexListener.onRevealHintClicked(position, /* isHintRevealed= */ true) - (fragment.requireActivity() as? RevealHintListener)?.revealHint(hintIndex = position) - expandOrCollapseItem(position) - } + binding.revealHintButton.setOnClickListener { + hintViewModel.isHintRevealed.set(true) + expandedHintListIndexListener.onRevealHintClicked(position, isHintRevealed = true) + (fragment.requireActivity() as? RevealHintListener)?.revealHint(hintIndex = position) + expandOrCollapseItem(position) } - binding.expandHintListIcon.setOnClickListener { - if (hintsViewModel.isHintRevealed.get()!!) { + binding.expandableHintHeader.setOnClickListener { + if (hintViewModel.isHintRevealed.get()) { expandOrCollapseItem(position) } } - - binding.root.setOnClickListener { - if (hintsViewModel.isHintRevealed.get()!!) { + binding.expandHintListIcon.setOnClickListener { + if (hintViewModel.isHintRevealed.get()) { expandOrCollapseItem(position) } } @@ -253,13 +202,13 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( } private fun expandOrCollapseItem(position: Int) { - if (expandedItemsList.contains(position)) { - expandedItemsList.remove(position) + if (position in expandedItemIndexes) { + expandedItemIndexes -= position } else { - expandedItemsList.add(position) + expandedItemIndexes += position } bindingAdapter.notifyItemChanged(position) - expandedHintListIndexListener.onExpandListIconClicked(expandedItemsList) + expandedHintListIndexListener.onExpandListIconClicked(ArrayList(expandedItemIndexes)) } private fun bindSolutionViewModel( @@ -268,8 +217,8 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( ) { binding.viewModel = solutionViewModel - val position: Int = itemList.indexOf(solutionViewModel) - binding.isListExpanded = expandedItemsList.contains(position) + val position: Int = viewModel.itemList.indexOf(solutionViewModel) + binding.isListExpanded = expandedItemIndexes.contains(position) solutionIndex?.let { solutionIndex -> isSolutionRevealed?.let { isSolutionRevealed -> @@ -279,41 +228,43 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( } } - binding.solutionTitle.text = - resourceHandler.capitalizeForHumans(solutionViewModel.title.get()!!) - // TODO(#1050): Update to display answers for any answer type. - if (solutionViewModel.correctAnswer.get().isNullOrEmpty()) { - binding.solutionCorrectAnswer.text = - resourceHandler.getStringInLocaleWithoutWrapping( - R.string.hints_android_solution_correct_answer, - solutionViewModel.numerator.get().toString(), - solutionViewModel.denominator.get().toString() - ) - } else { - binding.solutionCorrectAnswer.text = solutionViewModel.correctAnswer.get() - } - binding.solutionSummary.text = htmlParserFactory.create( - resourceBucketName, entityType, viewModel.explorationId.get()!!, /* imageCenterAlign= */ true, - displayLocale = resourceHandler.getDisplayLocale() - ).parseOppiaHtml( - solutionViewModel.solutionSummary.get()!!, binding.solutionSummary - ) + binding.solutionCorrectAnswer.text = + htmlParserFactory.create( + resourceBucketName, + entityType, + explorationId, + imageCenterAlign = true, + displayLocale = resourceHandler.getDisplayLocale() + ).parseOppiaHtml( + solutionViewModel.correctAnswerHtml, + binding.solutionCorrectAnswer + ) + binding.solutionSummary.text = + htmlParserFactory.create( + resourceBucketName, + entityType, + explorationId, + customOppiaTagActionListener = this, + imageCenterAlign = true, + displayLocale = resourceHandler.getDisplayLocale() + ).parseOppiaHtml( + solutionViewModel.solutionSummary, + binding.solutionSummary, + supportsLinks = true, + supportsConceptCards = true + ) - if (solutionViewModel.solutionCanBeRevealed.get()!!) { - binding.root.visibility = View.VISIBLE - binding.showSolutionButton.setOnClickListener { - showRevealSolutionDialogFragment() - } + binding.showSolutionButton.setOnClickListener { + showRevealSolutionDialogFragment() } - binding.expandSolutionListIcon.setOnClickListener { - if (solutionViewModel.isSolutionRevealed.get()!!) { + binding.expandableSolutionHeader.setOnClickListener { + if (solutionViewModel.isSolutionRevealed.get()) { expandOrCollapseItem(position) } } - - binding.root.setOnClickListener { - if (solutionViewModel.isSolutionRevealed.get()!!) { + binding.expandSolutionListIcon.setOnClickListener { + if (solutionViewModel.isSolutionRevealed.get()) { expandOrCollapseItem(position) } } @@ -338,16 +289,6 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( } } - private fun handleAllHintsExhausted(allHintsExhausted: Boolean) { - // The last item of the list is ReturnToLessonViewModel and therefore second last item is - // SolutionViewModel as a result subtracting 2 from itemList size. - if (itemList[itemList.size - 2] is SolutionViewModel) { - val solutionViewModel = itemList[itemList.size - 2] as SolutionViewModel - solutionViewModel.solutionCanBeRevealed.set(allHintsExhausted) - bindingAdapter.notifyItemChanged(itemList.size - 2) - } - } - private fun showRevealSolutionDialogFragment() { val previousFragment = fragment.childFragmentManager.findFragmentByTag(TAG_REVEAL_SOLUTION_DIALOG) @@ -358,29 +299,14 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( dialogFragment.showNow(fragment.childFragmentManager, TAG_REVEAL_SOLUTION_DIALOG) } - private fun getHintsAndSolutionViewModel(): HintsViewModel { - return viewModelProvider.getForFragment(fragment, HintsViewModel::class.java) - } - fun handleRevealSolution() { - if (itemList[itemList.size - 2] is SolutionViewModel) { - val solutionViewModel = itemList[itemList.size - 2] as SolutionViewModel - solutionViewModel.isSolutionRevealed.set(true) - expandedHintListIndexListener.onRevealSolutionClicked( - /* solutionIndex= */ itemList.size - 2, - /* isSolutionRevealed= */ true - ) - (fragment.requireActivity() as? RevealSolutionInterface)?.revealSolution() - expandOrCollapseItem(itemList.size - 2) - } - } - - private fun handleNewAvailableHint(hintIndex: Int?) { - if (itemList[hintIndex!!] is HintsViewModel) { - val hintsViewModel = itemList[hintIndex] as HintsViewModel - hintsViewModel.hintCanBeRevealed.set(true) - bindingAdapter.notifyItemChanged(hintIndex) - } + viewModel.isSolutionRevealed.set(true) + expandedHintListIndexListener.onRevealSolutionClicked( + solutionIndex = viewModel.solutionIndex, + isSolutionRevealed = true + ) + (fragment.requireActivity() as? RevealSolutionInterface)?.revealSolution() + expandOrCollapseItem(position = viewModel.solutionIndex) } fun onRevealHintClicked(index: Int?, isHintRevealed: Boolean?) { @@ -392,4 +318,10 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( this.solutionIndex = solutionIndex this.isSolutionRevealed = isSolutionRevealed } + + override fun onConceptCardLinkClicked(view: View, skillId: String) { + ConceptCardFragment + .newInstance(skillId, profileId) + .showNow(fragment.childFragmentManager, ConceptCardFragment.CONCEPT_CARD_DIALOG_FRAGMENT_TAG) + } } diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionViewModel.kt new file mode 100644 index 00000000000..f604f7f6547 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionViewModel.kt @@ -0,0 +1,121 @@ +package org.oppia.android.app.hintsandsolution + +import androidx.databinding.ObservableBoolean +import org.oppia.android.R +import org.oppia.android.app.model.HelpIndex +import org.oppia.android.app.model.Hint +import org.oppia.android.app.model.Solution +import org.oppia.android.app.model.State +import org.oppia.android.app.model.WrittenTranslationContext +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.viewmodel.ObservableViewModel +import org.oppia.android.domain.hintsandsolution.dropLastUnavailable +import org.oppia.android.domain.hintsandsolution.isHintRevealed +import org.oppia.android.domain.hintsandsolution.isSolutionAvailable +import org.oppia.android.domain.hintsandsolution.isSolutionRevealed +import org.oppia.android.domain.translation.TranslationController +import javax.inject.Inject + +/** + * View model for a lesson's hints/solution list. + * + * Instances of this class are created using its [Factory]. + */ +class HintsAndSolutionViewModel private constructor( + private val state: State, + private val helpIndex: HelpIndex, + private val writtenTranslationContext: WrittenTranslationContext, + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController, + private val solutionViewModelFactory: SolutionViewModel.Factory +) : ObservableViewModel() { + private val hintList by lazy { helpIndex.dropLastUnavailable(state.interaction.hintList) } + private val solution by lazy { + state.interaction.solution.takeIf { it.hasExplanation() && helpIndex.isSolutionAvailable() } + } + + /** + * The [ObservableBoolean] which indicates whether the solution is currently available to be + * viewed (that is, available to & revealed by the user). + */ + val isSolutionRevealed: ObservableBoolean by lazy { + ObservableBoolean(helpIndex.isSolutionRevealed()) + } + + /** + * The list of [HintsAndSolutionItemViewModel]s to display in the hint list represented by this + * model. + */ + val itemList: List by lazy { createViewModels() } + + /** + * The index into [itemList] where the solution would be present, if it's available to view + * (otherwise, this index corresponds to the most recently available hint). + */ + val solutionIndex: Int get() = itemList.lastIndex - 1 + + private fun createViewModels(): List { + return hintList.mapIndexed { index, hint -> + createHintViewModel( + index, hint, isHintRevealed = ObservableBoolean(helpIndex.isHintRevealed(index, hintList)) + ) + } + listOfNotNull(solution?.let(this::createSolutionViewModel)) + ReturnToLessonViewModel + } + + private fun createHintViewModel( + hintIndex: Int, + hint: Hint, + isHintRevealed: ObservableBoolean + ): HintViewModel { + return HintViewModel( + title = resourceHandler.getStringInLocaleWithWrapping( + R.string.hint_list_item_number, + resourceHandler.toHumanReadableString(hintIndex + 1) + ), + hintSummary = translationController.extractString( + hint.hintContent, writtenTranslationContext + ), + isHintRevealed = isHintRevealed + ) + } + + private fun createSolutionViewModel(solution: Solution): SolutionViewModel { + return solutionViewModelFactory.create( + solutionSummary = translationController.extractString( + solution.explanation, writtenTranslationContext + ), + isSolutionRevealed = isSolutionRevealed, + isSolutionExclusive = solution.answerIsExclusive, + correctAnswer = solution.correctAnswer, + interaction = state.interaction, + writtenTranslationContext = writtenTranslationContext + ) + } + + /** Application-injectable factory for creating [HintsAndSolutionViewModel]s (see [create]). */ + class Factory @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController, + private val solutionViewModelFactory: SolutionViewModel.Factory + ) { + /** + * Returns a new [HintsAndSolutionViewModel] that populates a list of item view models (to be + * bound to a recycler view) using the included [state] and [helpIndex] to source the current + * hints/solution state, and the provided [writtenTranslationContext] for localization. + */ + fun create( + state: State, + helpIndex: HelpIndex, + writtenTranslationContext: WrittenTranslationContext + ): HintsAndSolutionViewModel { + return HintsAndSolutionViewModel( + state, + helpIndex, + writtenTranslationContext, + resourceHandler, + translationController, + solutionViewModelFactory + ) + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt deleted file mode 100644 index b722a9f4a0b..00000000000 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt +++ /dev/null @@ -1,122 +0,0 @@ -package org.oppia.android.app.hintsandsolution - -import androidx.databinding.ObservableField -import androidx.lifecycle.ViewModel -import org.oppia.android.R -import org.oppia.android.app.fragment.FragmentScope -import org.oppia.android.app.model.HelpIndex -import org.oppia.android.app.model.Hint -import org.oppia.android.app.model.Solution -import org.oppia.android.app.model.WrittenTranslationContext -import org.oppia.android.app.translation.AppLanguageResourceHandler -import org.oppia.android.domain.hintsandsolution.isHintRevealed -import org.oppia.android.domain.hintsandsolution.isSolutionRevealed -import org.oppia.android.domain.translation.TranslationController -import org.oppia.android.util.parser.html.CustomHtmlContentHandler -import javax.inject.Inject - -private const val DEFAULT_HINT_AND_SOLUTION_SUMMARY = "" - -/** [ViewModel] for Hints in [HintsAndSolutionDialogFragment]. */ -@FragmentScope -class HintsViewModel @Inject constructor( - private val resourceHandler: AppLanguageResourceHandler, - private val translationController: TranslationController -) : HintsAndSolutionItemViewModel() { - - val newAvailableHintIndex = ObservableField(-1) - val allHintsExhausted = ObservableField(false) - val explorationId = ObservableField("") - - val title = ObservableField("") - val hintsAndSolutionSummary = ObservableField(DEFAULT_HINT_AND_SOLUTION_SUMMARY) - val isHintRevealed = ObservableField(false) - val hintCanBeRevealed = ObservableField(false) - - private lateinit var hintList: List - private lateinit var solution: Solution - private lateinit var helpIndex: HelpIndex - private lateinit var writtenTranslationContext: WrittenTranslationContext - val itemList: MutableList = ArrayList() - - /** Initializes the view model to display hints and a solution. */ - fun initialize( - helpIndex: HelpIndex, - hintList: List, - solution: Solution, - writtenTranslationContext: WrittenTranslationContext - ) { - this.helpIndex = helpIndex - this.hintList = hintList - this.solution = solution - this.writtenTranslationContext = writtenTranslationContext - } - - fun processHintList(): List { - itemList.clear() - for (index in hintList.indices) { - if (itemList.isEmpty()) { - addHintToList(index, hintList[index]) - } else { - val isPriorHintRevealed = (itemList.last() as HintsViewModel).isHintRevealed.get() ?: false - val availableHintIndex = newAvailableHintIndex.get() ?: 0 - if (isPriorHintRevealed && index <= availableHintIndex) { - addHintToList(index, hintList[index]) - } else break - } - } - if (itemList.isNotEmpty()) { - val isLastHintRevealed = (itemList.last() as HintsViewModel).isHintRevealed.get() ?: false - val areAllHintsExhausted = allHintsExhausted.get() ?: false - if (solution.hasExplanation() && - hintList.size == itemList.size && - isLastHintRevealed && - areAllHintsExhausted - ) { - addSolutionToList(solution) - } - } - itemList.add(ReturnToLessonViewModel()) - return itemList - } - - fun computeHintContentDescription(): String { - return hintsAndSolutionSummary.get()?.let { - CustomHtmlContentHandler.fromHtml( - it, - imageRetriever = null, - customTagHandlers = mapOf() - ).toString() - } ?: DEFAULT_HINT_AND_SOLUTION_SUMMARY - } - - private fun addHintToList(hintIndex: Int, hint: Hint) { - val hintsViewModel = HintsViewModel(resourceHandler, translationController) - hintsViewModel.title.set( - resourceHandler.getStringInLocaleWithWrapping( - R.string.hint_list_item_number, - resourceHandler.toHumanReadableString(hintIndex + 1) - ) - ) - val hintContentHtml = - translationController.extractString(hint.hintContent, writtenTranslationContext) - hintsViewModel.hintsAndSolutionSummary.set(hintContentHtml) - hintsViewModel.isHintRevealed.set(helpIndex.isHintRevealed(hintIndex, hintList)) - itemList.add(hintsViewModel) - } - - private fun addSolutionToList(solution: Solution) { - val solutionViewModel = SolutionViewModel() - solutionViewModel.title.set(solution.explanation.contentId) - solutionViewModel.correctAnswer.set(solution.correctAnswer.correctAnswer) - solutionViewModel.numerator.set(solution.correctAnswer.numerator) - solutionViewModel.denominator.set(solution.correctAnswer.denominator) - solutionViewModel.wholeNumber.set(solution.correctAnswer.wholeNumber) - solutionViewModel.isNegative.set(solution.correctAnswer.isNegative) - val explanationHtml = - translationController.extractString(solution.explanation, writtenTranslationContext) - solutionViewModel.solutionSummary.set(explanationHtml) - solutionViewModel.isSolutionRevealed.set(helpIndex.isSolutionRevealed()) - itemList.add(solutionViewModel) - } -} diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/ReturnToLessonViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/ReturnToLessonViewModel.kt index 374bb6fd1ef..2c8d4ee846c 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/ReturnToLessonViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/ReturnToLessonViewModel.kt @@ -3,4 +3,4 @@ package org.oppia.android.app.hintsandsolution import androidx.lifecycle.ViewModel /** [ViewModel] for return to lesson button in [HintsAndSolutionDialogFragment]. */ -class ReturnToLessonViewModel : HintsAndSolutionItemViewModel() +object ReturnToLessonViewModel : HintsAndSolutionItemViewModel() diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt index 82c1c38d997..cd70ceaae8e 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt @@ -1,28 +1,266 @@ package org.oppia.android.app.hintsandsolution -import androidx.databinding.ObservableField -import androidx.lifecycle.ViewModel +import androidx.databinding.ObservableBoolean +import org.oppia.android.R +import org.oppia.android.app.hintsandsolution.HintsAndSolutionViewModel.Factory +import org.oppia.android.app.model.Interaction +import org.oppia.android.app.model.InteractionObject +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.BOOL_VALUE +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.CLICK_ON_IMAGE +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.FRACTION +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.IMAGE_WITH_REGIONS +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.LIST_OF_SETS_OF_HTML_STRING +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.LIST_OF_SETS_OF_TRANSLATABLE_HTML_CONTENT_IDS +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.MATH_EXPRESSION +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.NORMALIZED_STRING +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.NUMBER_WITH_UNITS +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.OBJECTTYPE_NOT_SET +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.RATIO_EXPRESSION +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.REAL +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.SET_OF_HTML_STRING +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.SET_OF_TRANSLATABLE_HTML_CONTENT_IDS +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.SIGNED_INT +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.TRANSLATABLE_HTML_CONTENT_ID +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.TRANSLATABLE_SET_OF_NORMALIZED_STRING +import org.oppia.android.app.model.MathEquation +import org.oppia.android.app.model.MathExpression +import org.oppia.android.app.model.WrittenTranslationContext +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.utility.math.MathExpressionAccessibilityUtil +import org.oppia.android.app.utility.toAccessibleAnswerString +import org.oppia.android.util.math.MathExpressionParser.Companion.ErrorCheckingMode.REQUIRED_ONLY +import org.oppia.android.util.math.MathExpressionParser.Companion.MathParsingResult +import org.oppia.android.util.math.MathExpressionParser.Companion.parseAlgebraicEquation +import org.oppia.android.util.math.MathExpressionParser.Companion.parseAlgebraicExpression +import org.oppia.android.util.math.MathExpressionParser.Companion.parseNumericExpression +import org.oppia.android.util.math.isApproximatelyEqualTo +import org.oppia.android.util.math.toAnswerString +import org.oppia.android.util.math.toPlainString +import org.oppia.android.util.math.toRawLatex import org.oppia.android.util.parser.html.CustomHtmlContentHandler +import javax.inject.Inject -/** [ViewModel] for Solution in [HintsAndSolutionDialogFragment]. */ -class SolutionViewModel : HintsAndSolutionItemViewModel() { - val solutionSummary = ObservableField("") - val correctAnswer = ObservableField("") - val numerator = ObservableField() - val wholeNumber = ObservableField() - val denominator = ObservableField() - val isNegative = ObservableField(false) - val title = ObservableField("") - val isSolutionRevealed = ObservableField(false) - val solutionCanBeRevealed = ObservableField(false) - - fun computeSolutionContentDescription(): String { - return solutionSummary.get()?.let { - CustomHtmlContentHandler.fromHtml( - it, - imageRetriever = null, - customTagHandlers = mapOf() - ).toString() - } ?: "" +/** + * [HintsAndSolutionItemViewModel] that represents a solution that the user may reveal. + * + * Instances of this class are created using its [Factory]. + * + * @property solutionSummary the solution's explanation text (which may contain HTML) + * @property isSolutionRevealed whether the solution is currently expanded and viewable + */ +class SolutionViewModel private constructor( + val solutionSummary: String, + private val correctAnswer: InteractionObject, + val isSolutionRevealed: ObservableBoolean, + isSolutionExclusive: Boolean, + private val interaction: Interaction, + private val writtenTranslationContext: WrittenTranslationContext, + private val appLanguageResourceHandler: AppLanguageResourceHandler, + private val mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil +) : HintsAndSolutionItemViewModel() { + /** + * A screenreader-friendly version of [solutionSummary] that should be used for readout, in place + * of the original summary. + */ + val solutionSummaryContentDescription by lazy { + CustomHtmlContentHandler.fromHtml( + solutionSummary, + imageRetriever = null, + customTagHandlers = mapOf() + ).toString() + } + + /** A displayable HTML representation of the correct answer presented by this model's solution. */ + val correctAnswerHtml: String by lazy { computeCorrectAnswerHtml() } + + /** A screenreader-friendly readable version of [correctAnswerHtml]. */ + val correctAnswerContentDescription: String by lazy { computeCorrectAnswerContentDescription() } + + private val correctAnswerTextStringResId = if (isSolutionExclusive) { + R.string.hints_list_exclusive_solution_text + } else R.string.hints_list_possible_solution_text + + private fun computeCorrectAnswerHtml(): String { + val answerTextHtml = when (correctAnswer.objectTypeCase) { + NORMALIZED_STRING -> correctAnswer.normalizedString + REAL -> correctAnswer.real.toSimplifiedPlainString() + FRACTION -> correctAnswer.fraction.toAnswerString() + RATIO_EXPRESSION -> correctAnswer.ratioExpression.toAnswerString() + MATH_EXPRESSION -> when (interaction.id) { + "NumericExpressionInput" -> correctAnswer.mathExpression.toNumericExpressionHtml() + "AlgebraicExpressionInput" -> correctAnswer.mathExpression.toAlgebraicExpressionHtml() + "MathEquationInput" -> correctAnswer.mathExpression.toAlgebraicEquationHtml() + else -> error("Interaction ID not valid for math expressions: ${interaction.id}.") + } + LIST_OF_SETS_OF_TRANSLATABLE_HTML_CONTENT_IDS, SIGNED_INT, NON_NEGATIVE_INT, + TRANSLATABLE_SET_OF_NORMALIZED_STRING, TRANSLATABLE_HTML_CONTENT_ID, + SET_OF_TRANSLATABLE_HTML_CONTENT_IDS, SET_OF_HTML_STRING, LIST_OF_SETS_OF_HTML_STRING, + IMAGE_WITH_REGIONS, CLICK_ON_IMAGE, NUMBER_WITH_UNITS, BOOL_VALUE, OBJECTTYPE_NOT_SET, null -> + error("Invalid answer type for solution: $correctAnswer") + } + return appLanguageResourceHandler.getStringInLocaleWithWrapping( + correctAnswerTextStringResId, answerTextHtml + ) + } + + private fun computeCorrectAnswerContentDescription(): String { + val readableAnswerText = when (correctAnswer.objectTypeCase) { + NORMALIZED_STRING -> correctAnswer.normalizedString + REAL -> correctAnswer.real.toSimplifiedReadableString() + FRACTION -> correctAnswer.fraction.toAnswerString() + RATIO_EXPRESSION -> + correctAnswer.ratioExpression.toAccessibleAnswerString(appLanguageResourceHandler) + MATH_EXPRESSION -> when (interaction.id) { + "NumericExpressionInput" -> correctAnswer.mathExpression.toReadableNumericExpression() + "AlgebraicExpressionInput" -> correctAnswer.mathExpression.toReadableAlgebraicExpression() + "MathEquationInput" -> correctAnswer.mathExpression.toReadableAlgebraicEquation() + else -> error("Interaction ID not valid for math expressions: ${interaction.id}.") + } + LIST_OF_SETS_OF_TRANSLATABLE_HTML_CONTENT_IDS, SIGNED_INT, NON_NEGATIVE_INT, + TRANSLATABLE_SET_OF_NORMALIZED_STRING, TRANSLATABLE_HTML_CONTENT_ID, + SET_OF_TRANSLATABLE_HTML_CONTENT_IDS, SET_OF_HTML_STRING, LIST_OF_SETS_OF_HTML_STRING, + IMAGE_WITH_REGIONS, CLICK_ON_IMAGE, NUMBER_WITH_UNITS, BOOL_VALUE, OBJECTTYPE_NOT_SET, null -> + error("Invalid answer type for solution: $correctAnswer") + } + return appLanguageResourceHandler.getStringInLocaleWithWrapping( + correctAnswerTextStringResId, readableAnswerText + ) + } + + private fun String.toNumericExpressionHtml() = + parseNumericExpression().expectSuccess().toRawLatex().wrapAsLatexHtml() + + private fun String.toReadableNumericExpression() = + parseNumericExpression().expectSuccess().toReadableString() ?: this + + private fun String.toAlgebraicExpressionHtml() = + parseAlgebraicExpression().expectSuccess().toRawLatex().wrapAsLatexHtml() + + private fun String.toReadableAlgebraicExpression() = + parseAlgebraicExpression().expectSuccess().toReadableString() ?: this + + private fun String.toAlgebraicEquationHtml() = + parseAlgebraicEquation().expectSuccess().toRawLatex().wrapAsLatexHtml() + + private fun String.toReadableAlgebraicEquation() = + parseAlgebraicEquation().expectSuccess().toReadableString() ?: this + + private fun String.parseAlgebraicExpression(): MathParsingResult { + return parseAlgebraicExpression( + rawExpression = this, + allowedVariables = interaction.extractAllowedVariables(), + errorCheckingMode = REQUIRED_ONLY + ) + } + + private fun String.parseAlgebraicEquation(): MathParsingResult { + return parseAlgebraicEquation( + rawExpression = this, + allowedVariables = interaction.extractAllowedVariables(), + errorCheckingMode = REQUIRED_ONLY + ) + } + + private fun MathExpression.toRawLatex() = + toRawLatex(divAsFraction = interaction.extractUseFractionsForDivision()) + + private fun MathEquation.toRawLatex() = + toRawLatex(divAsFraction = interaction.extractUseFractionsForDivision()) + + private fun MathExpression.toReadableString(): String? { + return mathExpressionAccessibilityUtil.convertToHumanReadableString( + expression = this, + language = writtenTranslationContext.language, + divAsFraction = interaction.extractUseFractionsForDivision() + ) + } + + private fun MathEquation.toReadableString(): String? { + return mathExpressionAccessibilityUtil.convertToHumanReadableString( + equation = this, + language = writtenTranslationContext.language, + divAsFraction = interaction.extractUseFractionsForDivision() + ) + } + + private fun Double.toSimplifiedReadableString(): String { + val longPart = toLong() + return if (isApproximatelyEqualTo(longPart.toDouble())) { + appLanguageResourceHandler.formatLong(longPart) + } else appLanguageResourceHandler.formatDouble(this) + } + + private companion object { + /** + * Returns a plain-string representation of this [Double] with a preference toward dropping the + * decimal if it isn't needed for the final answer. + */ + private fun Double.toSimplifiedPlainString(): String { + val longPart = toLong() + return if (isApproximatelyEqualTo(longPart.toDouble())) { + longPart.toString() + } else toPlainString() + } + + private fun String.parseNumericExpression() = + parseNumericExpression(rawExpression = this, errorCheckingMode = REQUIRED_ONLY) + + private fun MathParsingResult.expectSuccess(): T { + return when (this) { + is MathParsingResult.Success -> result + is MathParsingResult.Failure -> error("Invalid parsing result: $error.") + } + } + + private fun String.wrapAsLatexHtml(): String { + val mathContentValue = + "{&quot;raw_latex&quot;:&quot;${this.replace("\\", "\\\\")}&quot;}" + return "" + } + + private fun Interaction.extractAllowedVariables(): List { + return customizationArgsMap["customOskLetters"] + ?.schemaObjectList + ?.schemaObjectList + ?.map { it.normalizedString } + ?: listOf() + } + + private fun Interaction.extractUseFractionsForDivision() = + customizationArgsMap["useFractionForDivision"]?.boolValue ?: false + } + + /** Application-injectable factory to create [SolutionViewModel]s (see [create]). */ + class Factory @Inject constructor( + private val appLanguageResourceHandler: AppLanguageResourceHandler, + private val mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil + ) { + /** + * Returns a new [SolutionViewModel] with the specified summary HTML text, correct answer, + * [isSolutionRevealed] tracking [ObservableBoolean], whether the solution is exclusive, and its + * answer context (such as the [interaction] that the answer can be submitted to and the + * displayed state's current [writtenTranslationContext] for localization). + */ + fun create( + solutionSummary: String, + correctAnswer: InteractionObject, + isSolutionRevealed: ObservableBoolean, + isSolutionExclusive: Boolean, + interaction: Interaction, + writtenTranslationContext: WrittenTranslationContext + ): SolutionViewModel { + return SolutionViewModel( + solutionSummary, + correctAnswer, + isSolutionRevealed, + isSolutionExclusive, + interaction, + writtenTranslationContext, + appLanguageResourceHandler, + mathExpressionAccessibilityUtil + ) + } } } diff --git a/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt index ae37501e728..eedc0483a0b 100644 --- a/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt @@ -13,6 +13,7 @@ import org.oppia.android.app.home.promotedlist.ComingSoonTopicListViewModel import org.oppia.android.app.home.promotedlist.PromotedStoryListViewModel import org.oppia.android.app.home.topiclist.AllTopicsViewModel import org.oppia.android.app.home.topiclist.TopicSummaryViewModel +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.TopicSummary import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.app.translation.AppLanguageResourceHandler @@ -24,6 +25,7 @@ import org.oppia.android.databinding.PromotedStoryListBinding import org.oppia.android.databinding.TopicSummaryViewBinding import org.oppia.android.databinding.WelcomeBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.domain.topic.TopicListController import org.oppia.android.domain.translation.TranslationController @@ -39,6 +41,7 @@ class HomeFragmentPresenter @Inject constructor( private val profileManagementController: ProfileManagementController, private val topicListController: TopicListController, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, @TopicHtmlParserEntityType private val topicEntityType: String, @StoryHtmlParserEntityType private val storyEntityType: String, private val resourceHandler: AppLanguageResourceHandler, @@ -157,6 +160,9 @@ class HomeFragmentPresenter @Inject constructor( } private fun logHomeActivityEvent() { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenHomeContext()) + analyticsController.logImportantEvent( + oppiaLogger.createOpenHomeContext(), + ProfileId.newBuilder().apply { internalId = internalProfileId }.build() + ) } } diff --git a/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt b/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt index 8de03ce0ca3..33708dcebb9 100644 --- a/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt @@ -117,7 +117,7 @@ class AudioViewModel @Inject constructor( val ensuredLanguageCode = if (languages.contains("en")) "en" else languages.first() fallbackLanguageCode = ensuredLanguageCode audioPlayerController.changeDataSource( - voiceOverToUri(voiceoverMap[ensuredLanguageCode]), currentContentId + voiceOverToUri(voiceoverMap[ensuredLanguageCode]), currentContentId, ensuredLanguageCode ) } } @@ -128,20 +128,20 @@ class AudioViewModel @Inject constructor( selectedLanguageCode = languageCode currentLanguageCode.set(languageCode) audioPlayerController.changeDataSource( - voiceOverToUri(voiceoverMap[languageCode]), currentContentId + voiceOverToUri(voiceoverMap[languageCode]), currentContentId, languageCode ) } /** Plays or pauses AudioController depending on passed in state */ fun togglePlayPause(type: UiAudioPlayStatus?) { if (type == UiAudioPlayStatus.PLAYING) { - audioPlayerController.pause() + audioPlayerController.pause(isFromExplicitUserAction = true) } else { audioPlayerController.play(isPlayingFromAutoPlay = false, reloadingMainContent = false) } } - fun pauseAudio() = audioPlayerController.pause() + fun pauseAudio() = audioPlayerController.pause(isFromExplicitUserAction = false) fun handleSeekTo(position: Int) = audioPlayerController.seekTo(position) fun handleRelease() = audioPlayerController.releaseMediaPlayer() diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt index 0698e869113..cedf1abaf54 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt @@ -45,7 +45,6 @@ class ExplorationActivity : RequestVoiceOverIconSpotlightListener { @Inject lateinit var explorationActivityPresenter: ExplorationActivityPresenter - private lateinit var state: State private lateinit var writtenTranslationContext: WrittenTranslationContext @@ -53,7 +52,7 @@ class ExplorationActivity : super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) - val params = intent.getProtoExtra(PARAMS_KEY, ExplorationActivityParams.getDefaultInstance()) + val params = intent.extractParams() explorationActivityPresenter.handleOnCreate( this, params.profileId, @@ -103,6 +102,9 @@ class ExplorationActivity : decorateWithScreenName(EXPLORATION_ACTIVITY) } } + + private fun Intent.extractParams() = + getProtoExtra(PARAMS_KEY, ExplorationActivityParams.getDefaultInstance()) } override fun onBackPressed() { @@ -162,7 +164,8 @@ class ExplorationActivity : explorationId, state, helpIndex, - writtenTranslationContext + writtenTranslationContext, + intent.extractParams().profileId ) hintsAndSolutionDialogFragment.showNow(supportFragmentManager, TAG_HINTS_AND_SOLUTION_DIALOG) } diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt index a28f18c0664..8796b96b672 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt @@ -11,6 +11,7 @@ import androidx.fragment.app.Fragment import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.ExplorationFragmentArguments +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.player.state.StateFragment @@ -21,6 +22,7 @@ import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.FontScaleConfigurationUtil import org.oppia.android.databinding.ExplorationFragmentBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData @@ -33,6 +35,7 @@ import javax.inject.Inject class ExplorationFragmentPresenter @Inject constructor( private val fragment: Fragment, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, private val fontScaleConfigurationUtil: FontScaleConfigurationUtil, private val profileManagementController: ProfileManagementController, private val resourceHandler: AppLanguageResourceHandler @@ -144,8 +147,9 @@ class ExplorationFragmentPresenter @Inject constructor( } private fun logPracticeFragmentEvent(topicId: String, storyId: String, explorationId: String) { - oppiaLogger.logImportantEvent( - oppiaLogger.createOpenExplorationActivityContext(topicId, storyId, explorationId) + analyticsController.logImportantEvent( + oppiaLogger.createOpenExplorationActivityContext(topicId, storyId, explorationId), + ProfileId.newBuilder().apply { internalId = internalProfileId }.build() ) } diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragmentPresenter.kt index 2f7e4613911..59ad3ac71eb 100644 --- a/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragmentPresenter.kt @@ -53,9 +53,10 @@ class HintsAndSolutionExplorationManagerFragmentPresenter @Inject constructor( is AsyncResult.Success -> { // Check if hints are available for this state. val ephemeralState = result.value - if (ephemeralState.state.interaction.hintList.size != 0) { + val state = ephemeralState.state + if (state.interaction.hintList.isNotEmpty() || state.interaction.hasSolution()) { (activity as HintsAndSolutionExplorationManagerListener).onExplorationStateLoaded( - ephemeralState.state, ephemeralState.writtenTranslationContext + state, ephemeralState.writtenTranslationContext ) } } 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 2dd09f26969..725ae21d1d6 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 @@ -38,7 +38,6 @@ import org.oppia.android.app.topic.conceptcard.ConceptCardFragment.Companion.CON import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.SplitScreenManager import org.oppia.android.app.utility.lifecycle.LifecycleSafeTimerFactory -import org.oppia.android.app.viewmodel.ViewModelProvider import org.oppia.android.databinding.StateFragmentBinding import org.oppia.android.domain.exploration.ExplorationProgressController import org.oppia.android.domain.oppialogger.OppiaLogger @@ -68,7 +67,6 @@ class StateFragmentPresenter @Inject constructor( private val fragment: Fragment, private val context: Context, private val lifecycleSafeTimerFactory: LifecycleSafeTimerFactory, - private val viewModelProvider: ViewModelProvider, private val explorationProgressController: ExplorationProgressController, private val storyProgressController: StoryProgressController, private val oppiaLogger: OppiaLogger, @@ -76,6 +74,7 @@ class StateFragmentPresenter @Inject constructor( private val assemblerBuilderFactory: StatePlayerRecyclerViewAssembler.Builder.Factory, private val splitScreenManager: SplitScreenManager, private val oppiaClock: OppiaClock, + private val viewModel: StateViewModel, private val accessibilityService: AccessibilityService, private val resourceHandler: AppLanguageResourceHandler ) { @@ -94,9 +93,6 @@ class StateFragmentPresenter @Inject constructor( private lateinit var helpIndex: HelpIndex private var forceAnnouncedForHintsBar = false - private val viewModel: StateViewModel by lazy { - getStateViewModel() - } private lateinit var recyclerViewAssembler: StatePlayerRecyclerViewAssembler private val ephemeralStateLiveData: LiveData> by lazy { explorationProgressController.getCurrentState().toLiveData() @@ -116,6 +112,7 @@ class StateFragmentPresenter @Inject constructor( this.topicId = topicId this.storyId = storyId this.explorationId = explorationId + viewModel.initializeProfile(profileId) binding = StateFragmentBinding.inflate( inflater, @@ -266,10 +263,6 @@ class StateFragmentPresenter @Inject constructor( subscribeToHintSolution(explorationProgressController.submitSolutionIsRevealed()) } - private fun getStateViewModel(): StateViewModel { - return viewModelProvider.getForFragment(fragment, StateViewModel::class.java) - } - private fun getAudioFragment(): Fragment? { return fragment.childFragmentManager.findFragmentByTag(TAG_AUDIO_FRAGMENT) } @@ -437,8 +430,7 @@ class StateFragmentPresenter @Inject constructor( ) } - fun setAudioBarVisibility(visibility: Boolean) = - getStateViewModel().setAudioBarVisibility(visibility) + fun setAudioBarVisibility(visibility: Boolean) = viewModel.setAudioBarVisibility(visibility) fun scrollToTop() { binding.stateRecyclerView.smoothScrollToPosition(0) diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/StateViewModel.kt index a46208966b1..b180c0e61d7 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/StateViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateViewModel.kt @@ -2,19 +2,45 @@ package org.oppia.android.app.player.state import androidx.databinding.ObservableField import androidx.databinding.ObservableList +import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.model.EphemeralState +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.Profile +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.WrittenTranslationLanguageSelection import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel import org.oppia.android.app.viewmodel.ObservableArrayList import org.oppia.android.app.viewmodel.ObservableViewModel +import org.oppia.android.domain.exploration.ExplorationProgressController +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.profile.ProfileManagementController +import org.oppia.android.domain.translation.TranslationController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import org.oppia.android.util.locale.OppiaLocale +import org.oppia.android.util.platformparameter.EnableLearnerStudyAnalytics +import org.oppia.android.util.platformparameter.PlatformParameterValue import javax.inject.Inject /** [ViewModel] for state-fragment. */ @FragmentScope -class StateViewModel @Inject constructor() : ObservableViewModel() { +class StateViewModel @Inject constructor( + private val explorationProgressController: ExplorationProgressController, + private val translationController: TranslationController, + private val machineLocale: OppiaLocale.MachineLocale, + private val oppiaLogger: OppiaLogger, + private val fragment: Fragment, + private val profileManagementController: ProfileManagementController, + @EnableLearnerStudyAnalytics private val enableLearnerStudy: PlatformParameterValue +) : ObservableViewModel() { val itemList: ObservableList = ObservableArrayList() val rightItemList: ObservableList = ObservableArrayList() @@ -26,9 +52,35 @@ class StateViewModel @Inject constructor() : ObservableViewModel() { val isHintBulbVisible = ObservableField(false) val isHintOpenedAndUnRevealed = ObservableField(false) + val hasSupportForSwitchingToSwahili: Boolean = enableLearnerStudy.value + val hasSwahiliTranslations: LiveData by lazy { + Transformations.map( + explorationProgressController.getCurrentState().toLiveData(), + ::processWhetherSwahiliIsSupported + ) + } + val hasEnabledSwahiliTranslations: LiveData by lazy { + Transformations.map( + translationController.getWrittenTranslationContentLanguage(profileId).toLiveData(), + ::processIsCurrentLanguageSwahili + ) + } + + val allowInLessonQuickLanguageSwitching: LiveData by lazy { + Transformations.map( + profileManagementController.getProfile(profileId).toLiveData(), + ::processAllowInLessonQuickLanguageSwitching + ) + } + var currentStateName = ObservableField(null as? String?) private val canSubmitAnswer = ObservableField(false) + private lateinit var profileId: ProfileId + + fun initializeProfile(profileId: ProfileId) { + this.profileId = profileId + } fun setAudioBarVisibility(audioBarVisible: Boolean) { isAudioBarVisible.set(audioBarVisible) @@ -56,6 +108,47 @@ class StateViewModel @Inject constructor() : ObservableViewModel() { ) ?: UserAnswer.getDefaultInstance() } + fun canQuicklyToggleBetweenSwahiliAndEnglish( + hasSwahiliTranslations: Boolean, + allowInLessonLangSwitching: Boolean + ): Boolean { + // This logic has to be done in Kotlin since there seems to be a bug in the generated Java by + // the databinding compiler that can result in a NPE being thrown in code that shouldn't + // actually be throwing it (see https://issuetracker.google.com/issues/144246528 for context). + // Essentially, the following example of generated code results in an NPE unexpectedly: + // Boolean value = boolean_value ? Boolean_value : false (Boolean_value can be null) + return hasSwahiliTranslations && hasSupportForSwitchingToSwahili && allowInLessonLangSwitching + } + + fun toggleContentLanguage(isSwahiliEnabled: Boolean) { + val languageSelection = WrittenTranslationLanguageSelection.newBuilder().apply { + selectedLanguage = if (isSwahiliEnabled) OppiaLanguage.ENGLISH else OppiaLanguage.SWAHILI + }.build() + val updateResultProvider = + explorationProgressController.updateWrittenTranslationContentLanguageMidLesson( + profileId, languageSelection + ) + val updateResultLiveData = updateResultProvider.toLiveData() + updateResultLiveData.observe( + fragment, + object : Observer> { + override fun onChanged(result: AsyncResult?) { + if (result is AsyncResult.Failure) { + oppiaLogger.e( + "StateViewModel", + "Failed to update content language to:" + + " ${languageSelection.selectedLanguage.ordinal}.", + result.error + ) + } + if (result !is AsyncResult.Pending) { + updateResultLiveData.removeObserver(this) + } + } + } + ) + } + private fun getPendingAnswerWithoutError( answerHandler: InteractionAnswerHandler? ): UserAnswer? { @@ -73,4 +166,55 @@ class StateViewModel @Inject constructor() : ObservableViewModel() { itemList } } + + private fun processIsCurrentLanguageSwahili(languageResult: AsyncResult): Boolean { + return when (languageResult) { + is AsyncResult.Pending -> false + is AsyncResult.Success -> languageResult.value == OppiaLanguage.SWAHILI + is AsyncResult.Failure -> { + oppiaLogger.e( + "StateViewModel", "Failed to retrieve content language.", languageResult.error + ) + false + } + } + } + + private fun processAllowInLessonQuickLanguageSwitching( + profileResult: AsyncResult + ): Boolean { + return when (profileResult) { + is AsyncResult.Pending -> false // Assume the setting is off until verified. + is AsyncResult.Success -> profileResult.value.allowInLessonQuickLanguageSwitching + is AsyncResult.Failure -> { + oppiaLogger.e( + "StateViewModel", + "Failed to retrieve profile for current ID: $profileId.", + profileResult.error + ) + false // Assume the setting is off since a retrieval error occurred. + } + } + } + + private fun processWhetherSwahiliIsSupported(stateResult: AsyncResult): Boolean { + return when (stateResult) { + is AsyncResult.Pending -> false + is AsyncResult.Success -> { + // It would be nice if there was a domain utility to do this if it's needed elsewhere (or, + // better yet, just using the language protos directly in the state structure so no raw + // language codes need to be processed). + val state = stateResult.value.state + state.writtenTranslationsMap[state.content.contentId]?.translationMappingMap?.keys?.any { + // Only enable in-lesson language switching if the main content of a state is available in + // Swahili. + machineLocale.run { it.toMachineLowerCase() == "sw" } + } ?: false + } + is AsyncResult.Failure -> { + oppiaLogger.e("StateViewModel", "Failed to retrieve state.", stateResult.error) + false + } + } + } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt index fcecfe7d311..26e077ab277 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt @@ -10,6 +10,7 @@ import org.oppia.android.app.hintsandsolution.HintsAndSolutionListener import org.oppia.android.app.hintsandsolution.RevealHintListener import org.oppia.android.app.hintsandsolution.RevealSolutionInterface 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.WrittenTranslationContext import org.oppia.android.app.player.audio.AudioButtonListener @@ -46,10 +47,14 @@ class StateFragmentTestActivity : lateinit var stateFragmentTestActivityPresenter: StateFragmentTestActivityPresenter private lateinit var state: State private lateinit var writtenTranslationContext: WrittenTranslationContext + private lateinit var profileId: ProfileId override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) + profileId = ProfileId.newBuilder().apply { + internalId = intent.getIntExtra(TEST_ACTIVITY_PROFILE_ID_EXTRA_KEY, -1) + }.build() stateFragmentTestActivityPresenter.handleOnCreate() } @@ -113,7 +118,8 @@ class StateFragmentTestActivity : explorationId, state, helpIndex, - writtenTranslationContext + writtenTranslationContext, + profileId ) hintsAndSolutionFragment.showNow(supportFragmentManager, TAG_HINTS_AND_SOLUTION_DIALOG) } diff --git a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt index eefe86c8c99..801ea6f23d9 100644 --- a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt @@ -24,6 +24,7 @@ import org.oppia.android.databinding.ProfileChooserAddViewBinding import org.oppia.android.databinding.ProfileChooserFragmentBinding import org.oppia.android.databinding.ProfileChooserProfileViewBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData @@ -66,6 +67,7 @@ class ProfileChooserFragmentPresenter @Inject constructor( private val viewModelProvider: ViewModelProvider, private val profileManagementController: ProfileManagementController, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory ) { private lateinit var binding: ProfileChooserFragmentBinding @@ -254,7 +256,10 @@ class ProfileChooserFragmentPresenter @Inject constructor( } private fun logProfileChooserEvent() { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenProfileChooserContext()) + analyticsController.logImportantEvent( + oppiaLogger.createOpenProfileChooserContext(), + profileId = null // There's no profile currently logged in. + ) } private fun updateLearnerIdIfAbsent(profile: Profile) { diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditActivity.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditActivity.kt index f53b355b630..9399596698b 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditActivity.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditActivity.kt @@ -19,7 +19,7 @@ const val IS_MULTIPANE_EXTRA_KEY = "ProfileEditActivity.is_multipane" const val IS_PROFILE_DELETION_DIALOG_VISIBLE_KEY = "ProfileEditActivity.is_profile_deletion_dialog_visible" -/** Activity [ProfileEditActivity] that allows user to edit a profile. */ +/** Activity that allows admins to edit a profile. */ class ProfileEditActivity : InjectableAppCompatActivity() { @Inject lateinit var profileEditActivityPresenter: ProfileEditActivityPresenter diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditFragmentPresenter.kt index 914bf3bade4..f1fb73492b1 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditFragmentPresenter.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.Observer import org.oppia.android.R import org.oppia.android.app.administratorcontrols.AdministratorControlsActivity import org.oppia.android.app.administratorcontrols.ProfileEditDeletionDialogListener +import org.oppia.android.app.devoptions.markchapterscompleted.MarkChaptersCompletedActivity import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.ProfileId import org.oppia.android.databinding.ProfileEditFragmentBinding @@ -74,42 +75,57 @@ class ProfileEditFragmentPresenter @Inject constructor( ) } + binding.profileMarkChaptersForCompletionButton?.setOnClickListener { + activity.startActivity( + MarkChaptersCompletedActivity.createMarkChaptersCompletedIntent( + activity, internalProfileId, showConfirmationNotice = true + ) + ) + } + binding.profileDeleteButton.setOnClickListener { showDeletionDialog(internalProfileId) } - profileEditViewModel.profile.observe( - fragment, - Observer { - if (activity is ProfileEditActivity) { - activity.title = it.name - } + profileEditViewModel.profile.observe(fragment) { profile -> + if (activity is ProfileEditActivity) { + activity.title = profile.name } - ) - profileEditViewModel.isAllowedDownloadAccess.observe( - fragment, - Observer { - binding.profileEditAllowDownloadSwitch.isChecked = it - } - ) + binding.profileEditAllowDownloadSwitch.isChecked = profile.allowDownloadAccess + binding.profileEditEnableInLessonLanguageSwitchingSwitch.isChecked = + profile.allowInLessonQuickLanguageSwitching + } binding.profileEditAllowDownloadContainer.setOnClickListener { - binding.profileEditAllowDownloadSwitch.isChecked = - !binding.profileEditAllowDownloadSwitch.isChecked + val enableDownloads = !binding.profileEditAllowDownloadSwitch.isChecked + binding.profileEditAllowDownloadSwitch.isChecked = enableDownloads profileManagementController.updateAllowDownloadAccess( ProfileId.newBuilder().setInternalId(internalProfileId).build(), - binding.profileEditAllowDownloadSwitch.isChecked - ).toLiveData().observe( - activity, - Observer { - if (it is AsyncResult.Failure) { - oppiaLogger.e( - "ProfileEditActivityPresenter", "Failed to updated allow download access", it.error - ) - } + enableDownloads + ).toLiveData().observe(activity) { + if (it is AsyncResult.Failure) { + oppiaLogger.e( + "ProfileEditActivityPresenter", "Failed to updated allow download access", it.error + ) } - ) + } + } + binding.profileEditEnableInLessonLanguageSwitchingContainer.setOnClickListener { + val enableLangSwitching = !binding.profileEditEnableInLessonLanguageSwitchingSwitch.isChecked + binding.profileEditEnableInLessonLanguageSwitchingSwitch.isChecked = enableLangSwitching + profileManagementController.updateEnableInLessonQuickLanguageSwitching( + ProfileId.newBuilder().setInternalId(internalProfileId).build(), + enableLangSwitching + ).toLiveData().observe(activity) { + if (it is AsyncResult.Failure) { + oppiaLogger.e( + "ProfileEditActivityPresenter", + "Failed to updated allow quick language switching", + it.error + ) + } + } } return binding.root } diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditViewModel.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditViewModel.kt index c15aa25d21b..8aa02c1d428 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditViewModel.kt @@ -1,7 +1,6 @@ package org.oppia.android.app.settings.profile import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.Profile @@ -12,6 +11,7 @@ import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.platformparameter.EnableDownloadsSupport +import org.oppia.android.util.platformparameter.EnableLearnerStudyAnalytics import org.oppia.android.util.platformparameter.PlatformParameterValue import javax.inject.Inject @@ -20,14 +20,16 @@ import javax.inject.Inject class ProfileEditViewModel @Inject constructor( private val oppiaLogger: OppiaLogger, private val profileManagementController: ProfileManagementController, - @EnableDownloadsSupport private val enableDownloadsSupport: PlatformParameterValue + @EnableDownloadsSupport private val enableDownloadsSupport: PlatformParameterValue, + @EnableLearnerStudyAnalytics private val enableLearnerStudy: PlatformParameterValue ) : ObservableViewModel() { private lateinit var profileId: ProfileId - private val isAllowedDownloadAccessMutableLiveData = MutableLiveData() + /** Whether the admin is allowed to mark chapters as finished. */ + val isAllowedToMarkFinishedChapters: Boolean = enableLearnerStudy.value - /** Download access enabled for the profile by the administrator. */ - val isAllowedDownloadAccess: LiveData = isAllowedDownloadAccessMutableLiveData + /** Whether the admin can allow learners to quickly switch content languages within a lesson. */ + val isAllowedToEnableQuickLessonLanguageSwitching: Boolean = enableLearnerStudy.value /** List of all the current profiles registered in the app [ProfileListFragment]. */ val profile: LiveData by lazy { @@ -66,7 +68,6 @@ class ProfileEditViewModel @Inject constructor( is AsyncResult.Pending -> Profile.getDefaultInstance() is AsyncResult.Success -> profileResult.value } - isAllowedDownloadAccessMutableLiveData.value = profile.allowDownloadAccess isAdmin = profile.isAdmin return profile } diff --git a/app/src/main/java/org/oppia/android/app/story/StoryFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/story/StoryFragmentPresenter.kt index 7958dfcc8b0..7ec6ce8317d 100644 --- a/app/src/main/java/org/oppia/android/app/story/StoryFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/story/StoryFragmentPresenter.kt @@ -35,6 +35,7 @@ import org.oppia.android.databinding.StoryFragmentBinding import org.oppia.android.databinding.StoryHeaderViewBinding import org.oppia.android.domain.exploration.ExplorationDataController import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.util.accessibility.AccessibilityService import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData @@ -48,6 +49,7 @@ class StoryFragmentPresenter @Inject constructor( private val activity: AppCompatActivity, private val fragment: Fragment, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, private val htmlParserFactory: HtmlParser.Factory, private val explorationDataController: ExplorationDataController, @DefaultResourceBucketName private val resourceBucketName: String, @@ -61,12 +63,10 @@ class StoryFragmentPresenter @Inject constructor( private lateinit var binding: StoryFragmentBinding private lateinit var linearLayoutManager: LinearLayoutManager private lateinit var linearSmoothScroller: RecyclerView.SmoothScroller + private lateinit var profileId: ProfileId - @Inject - lateinit var storyViewModel: StoryViewModel - - @Inject - lateinit var accessibilityService: AccessibilityService + @Inject lateinit var storyViewModel: StoryViewModel + @Inject lateinit var accessibilityService: AccessibilityService fun handleCreateView( inflater: LayoutInflater, @@ -80,6 +80,7 @@ class StoryFragmentPresenter @Inject constructor( container, /* attachToRoot= */ false ) + profileId = ProfileId.newBuilder().apply { internalId = internalProfileId }.build() storyViewModel.setInternalProfileId(internalProfileId) storyViewModel.setTopicId(topicId) storyViewModel.setStoryId(storyId) @@ -255,7 +256,10 @@ class StoryFragmentPresenter @Inject constructor( } private fun logStoryActivityEvent(topicId: String, storyId: String) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenStoryActivityContext(topicId, storyId)) + analyticsController.logImportantEvent( + oppiaLogger.createOpenStoryActivityContext(topicId, storyId), + profileId + ) } private fun playExploration( diff --git a/app/src/main/java/org/oppia/android/app/topic/TopicFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/TopicFragmentPresenter.kt index f04092a39b6..b94b7db6029 100644 --- a/app/src/main/java/org/oppia/android/app/topic/TopicFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/TopicFragmentPresenter.kt @@ -11,6 +11,7 @@ import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.Spotlight import org.oppia.android.app.spotlight.SpotlightManager import org.oppia.android.app.spotlight.SpotlightShape @@ -18,6 +19,7 @@ import org.oppia.android.app.spotlight.SpotlightTarget import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.TopicFragmentBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.util.platformparameter.EnableExtraTopicTabsUi import org.oppia.android.util.platformparameter.PlatformParameterValue import javax.inject.Inject @@ -29,6 +31,7 @@ class TopicFragmentPresenter @Inject constructor( private val fragment: Fragment, private val viewModel: TopicViewModel, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, @EnableExtraTopicTabsUi private val enableExtraTopicTabsUi: PlatformParameterValue, private val resourceHandler: AppLanguageResourceHandler ) { @@ -111,7 +114,6 @@ class TopicFragmentPresenter @Inject constructor( private fun setCurrentTab(tab: TopicTab) { viewPager.setCurrentItem(computeTabPosition(tab), true) - logTopicEvents(tab) } private fun computeTabPosition(tab: TopicTab): Int { @@ -135,30 +137,22 @@ class TopicFragmentPresenter @Inject constructor( setCurrentTab(TopicTab.LESSONS) } } + viewPager2.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + logTopicEvents(TopicTab.getTabForPosition(position, enableExtraTopicTabsUi.value)) + } + }) } private fun logTopicEvents(tab: TopicTab) { - when (tab) { - TopicTab.INFO -> logInfoFragmentEvent(topicId) - TopicTab.LESSONS -> logLessonsFragmentEvent(topicId) - TopicTab.PRACTICE -> logPracticeFragmentEvent(topicId) - TopicTab.REVISION -> logRevisionFragmentEvent(topicId) + val eventContext = when (tab) { + TopicTab.INFO -> oppiaLogger.createOpenInfoTabContext(topicId) + TopicTab.LESSONS -> oppiaLogger.createOpenLessonsTabContext(topicId) + TopicTab.PRACTICE -> oppiaLogger.createOpenPracticeTabContext(topicId) + TopicTab.REVISION -> oppiaLogger.createOpenRevisionTabContext(topicId) } - } - - private fun logInfoFragmentEvent(topicId: String) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenInfoTabContext(topicId)) - } - - private fun logLessonsFragmentEvent(topicId: String) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenLessonsTabContext(topicId)) - } - - private fun logPracticeFragmentEvent(topicId: String) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenPracticeTabContext(topicId)) - } - - private fun logRevisionFragmentEvent(topicId: String) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenRevisionTabContext(topicId)) + analyticsController.logImportantEvent( + eventContext, ProfileId.newBuilder().apply { internalId = internalProfileId }.build() + ) } } diff --git a/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragmentPresenter.kt index 4b731ea971f..be54a5e57af 100644 --- a/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragmentPresenter.kt @@ -11,6 +11,7 @@ import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.viewmodel.ViewModelProvider import org.oppia.android.databinding.ConceptCardFragmentBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.parser.html.ConceptCardHtmlParserEntityType @@ -22,13 +23,16 @@ import javax.inject.Inject class ConceptCardFragmentPresenter @Inject constructor( private val fragment: Fragment, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, private val htmlParserFactory: HtmlParser.Factory, @ConceptCardHtmlParserEntityType private val entityType: String, @DefaultResourceBucketName private val resourceBucketName: String, private val viewModelProvider: ViewModelProvider, private val translationController: TranslationController, private val appLanguageResourceHandler: AppLanguageResourceHandler -) { +) : HtmlParser.CustomOppiaTagActionListener { + private lateinit var profileId: ProfileId + /** * Sets up data binding and toolbar. * Host activity must inherit ConceptCardListener to dismiss this fragment. @@ -39,6 +43,7 @@ class ConceptCardFragmentPresenter @Inject constructor( skillId: String, profileId: ProfileId ): View? { + this.profileId = profileId val binding = ConceptCardFragmentBinding.inflate( inflater, container, @@ -64,26 +69,28 @@ class ConceptCardFragmentPresenter @Inject constructor( } viewModel.conceptCardLiveData.observe( - fragment, - { ephemeralConceptCard -> - val explanationHtml = - translationController.extractString( - ephemeralConceptCard.conceptCard.explanation, - ephemeralConceptCard.writtenTranslationContext - ) - view.text = htmlParserFactory - .create( - resourceBucketName, - entityType, - skillId, - imageCenterAlign = true, - displayLocale = appLanguageResourceHandler.getDisplayLocale() - ) - .parseOppiaHtml( - explanationHtml, view - ) - } - ) + fragment + ) { ephemeralConceptCard -> + val explanationHtml = + translationController.extractString( + ephemeralConceptCard.conceptCard.explanation, + ephemeralConceptCard.writtenTranslationContext + ) + view.text = + htmlParserFactory.create( + resourceBucketName, + entityType, + skillId, + customOppiaTagActionListener = this, + imageCenterAlign = true, + displayLocale = appLanguageResourceHandler.getDisplayLocale() + ).parseOppiaHtml( + explanationHtml, + view, + supportsLinks = true, + supportsConceptCards = true + ) + } return binding.root } @@ -93,6 +100,14 @@ class ConceptCardFragmentPresenter @Inject constructor( } private fun logConceptCardEvent(skillId: String) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenConceptCardContext(skillId)) + analyticsController.logImportantEvent( + oppiaLogger.createOpenConceptCardContext(skillId), profileId + ) + } + + override fun onConceptCardLinkClicked(view: View, skillId: String) { + ConceptCardFragment + .newInstance(skillId, profileId) + .showNow(fragment.childFragmentManager, ConceptCardFragment.CONCEPT_CARD_DIALOG_FRAGMENT_TAG) } } diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivity.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivity.kt index e586b89ba6b..752b1f1cf17 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivity.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivity.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity -import org.oppia.android.app.hintsandsolution.HintsAndSolutionDialogFragment import org.oppia.android.app.hintsandsolution.HintsAndSolutionListener import org.oppia.android.app.hintsandsolution.RevealHintListener import org.oppia.android.app.hintsandsolution.RevealSolutionInterface @@ -14,7 +13,6 @@ import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.ScreenName.QUESTION_PLAYER_ACTIVITY import org.oppia.android.app.model.State import org.oppia.android.app.model.WrittenTranslationContext -import org.oppia.android.app.player.exploration.TAG_HINTS_AND_SOLUTION_DIALOG import org.oppia.android.app.player.state.listener.RouteToHintsAndSolutionListener import org.oppia.android.app.player.state.listener.StateKeyboardButtonListener import org.oppia.android.app.player.stopplaying.RestartPlayingSessionListener @@ -48,9 +46,6 @@ class QuestionPlayerActivity : @Inject lateinit var questionPlayerActivityPresenter: QuestionPlayerActivityPresenter - private lateinit var state: State - private lateinit var writtenTranslationContext: WrittenTranslationContext - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) @@ -108,39 +103,19 @@ class QuestionPlayerActivity : questionPlayerActivityPresenter.revealSolution() } - private fun getHintsAndSolution(): HintsAndSolutionDialogFragment? { - return supportFragmentManager.findFragmentByTag( - TAG_HINTS_AND_SOLUTION_DIALOG - ) as HintsAndSolutionDialogFragment? - } - override fun routeToHintsAndSolution( - questionId: String, + id: String, helpIndex: HelpIndex ) { - if (getHintsAndSolution() == null) { - val hintsAndSolutionDialogFragment = - HintsAndSolutionDialogFragment.newInstance( - questionId, - state, - helpIndex, - writtenTranslationContext - ) - hintsAndSolutionDialogFragment.showNow(supportFragmentManager, TAG_HINTS_AND_SOLUTION_DIALOG) - } + questionPlayerActivityPresenter.routeToHintsAndSolution(id, helpIndex) } - override fun dismiss() { - getHintsAndSolution()?.dismiss() - } + override fun dismiss() = questionPlayerActivityPresenter.dismissHintsAndSolutionDialog() override fun onQuestionStateLoaded( state: State, writtenTranslationContext: WrittenTranslationContext - ) { - this.state = state - this.writtenTranslationContext = writtenTranslationContext - } + ) = questionPlayerActivityPresenter.loadQuestionState(state, writtenTranslationContext) override fun dismissConceptCard() { questionPlayerActivityPresenter.dismissConceptCard() diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt index 6e58b27a68e..b9685035387 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt @@ -5,7 +5,12 @@ 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.hintsandsolution.HintsAndSolutionDialogFragment +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.WrittenTranslationContext +import org.oppia.android.app.player.exploration.TAG_HINTS_AND_SOLUTION_DIALOG import org.oppia.android.databinding.QuestionPlayerActivityBinding import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.question.QuestionTrainingController @@ -24,6 +29,8 @@ class QuestionPlayerActivityPresenter @Inject constructor( private val oppiaLogger: OppiaLogger ) { private lateinit var profileId: ProfileId + private lateinit var state: State + private lateinit var writtenTranslationContext: WrittenTranslationContext fun handleOnCreate(profileId: ProfileId) { this.profileId = profileId @@ -155,6 +162,30 @@ class QuestionPlayerActivityPresenter @Inject constructor( ) as QuestionPlayerFragment? } + fun loadQuestionState(state: State, writtenTranslationContext: WrittenTranslationContext) { + this.state = state + this.writtenTranslationContext = writtenTranslationContext + } + + fun routeToHintsAndSolution( + questionId: String, + helpIndex: HelpIndex + ) { + if (getHintsAndSolutionDialogFragment() == null) { + val hintsAndSolutionDialogFragment = + HintsAndSolutionDialogFragment.newInstance( + questionId, + state, + helpIndex, + writtenTranslationContext, + profileId + ) + hintsAndSolutionDialogFragment.showNow( + activity.supportFragmentManager, TAG_HINTS_AND_SOLUTION_DIALOG + ) + } + } + fun revealHint(hintIndex: Int) { val questionPlayerFragment = activity.supportFragmentManager.findFragmentByTag( @@ -171,5 +202,15 @@ class QuestionPlayerActivityPresenter @Inject constructor( questionPlayerFragment.revealSolution() } + fun dismissHintsAndSolutionDialog() { + getHintsAndSolutionDialogFragment()?.dismiss() + } + fun dismissConceptCard() = getQuestionPlayerFragment()?.dismissConceptCard() + + private fun getHintsAndSolutionDialogFragment(): HintsAndSolutionDialogFragment? { + return activity.supportFragmentManager.findFragmentByTag( + TAG_HINTS_AND_SOLUTION_DIALOG + ) as? HintsAndSolutionDialogFragment + } } diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt index 596ee8287d8..a9d3ceb558e 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt @@ -30,6 +30,7 @@ import org.oppia.android.app.utility.SplitScreenManager import org.oppia.android.app.viewmodel.ViewModelProvider import org.oppia.android.databinding.QuestionPlayerFragmentBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.question.QuestionAssessmentProgressController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProvider @@ -45,6 +46,7 @@ class QuestionPlayerFragmentPresenter @Inject constructor( private val viewModelProvider: ViewModelProvider, private val questionAssessmentProgressController: QuestionAssessmentProgressController, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, @QuestionResourceBucketName private val resourceBucketName: String, private val assemblerBuilderFactory: StatePlayerRecyclerViewAssembler.Builder.Factory, private val splitScreenManager: SplitScreenManager @@ -64,6 +66,7 @@ class QuestionPlayerFragmentPresenter @Inject constructor( private lateinit var questionId: String private lateinit var currentQuestionState: State private lateinit var helpIndex: HelpIndex + private lateinit var profileId: ProfileId fun handleCreateView( inflater: LayoutInflater, @@ -75,6 +78,7 @@ class QuestionPlayerFragmentPresenter @Inject constructor( container, /* attachToRoot= */ false ) + this.profileId = profileId recyclerViewAssembler = createRecyclerViewAssembler( assemblerBuilderFactory.create(resourceBucketName, "skill", profileId), @@ -354,8 +358,9 @@ class QuestionPlayerFragmentPresenter @Inject constructor( } private fun logQuestionPlayerEvent(questionId: String, skillIds: List) { - oppiaLogger.logImportantEvent( - oppiaLogger.createOpenQuestionPlayerContext(questionId, skillIds) + analyticsController.logImportantEvent( + oppiaLogger.createOpenQuestionPlayerContext(questionId, skillIds), + profileId ) } diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/ReturnToTopicClickListener.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/ReturnToTopicClickListener.kt index 1477507296a..29401c9ef87 100644 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/ReturnToTopicClickListener.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/ReturnToTopicClickListener.kt @@ -1,6 +1,7 @@ package org.oppia.android.app.topic.revisioncard -/** Listener to route to [TopicActivity] when clicked on Return to Topic button. */ +/** Listener for when user wishes to navigate back from a revision card to the topic screen. */ interface ReturnToTopicClickListener { - fun onReturnToTopicClicked() + /** Indicates that the user wishes to return to the topic screen from a revision card. */ + fun onReturnToTopicRequested() } diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt index 27a94b1a706..627d85ce6b6 100644 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt @@ -88,11 +88,16 @@ class RevisionCardActivity : this.finish() } - override fun onReturnToTopicClicked() { - onBackPressed() + override fun onReturnToTopicRequested() { + revisionCardActivityPresenter.logExitRevisionCard() + finish() } override fun dismissConceptCard() { revisionCardActivityPresenter.dismissConceptCard() } + + override fun onBackPressed() { + onReturnToTopicRequested() + } } diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityPresenter.kt index a307c330cbc..1cc5c331db5 100644 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityPresenter.kt @@ -16,6 +16,7 @@ import org.oppia.android.app.options.OptionsActivity import org.oppia.android.app.player.exploration.BottomSheetOptionsMenu import org.oppia.android.databinding.RevisionCardActivityBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.topic.TopicController import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.data.AsyncResult @@ -27,6 +28,7 @@ import javax.inject.Inject class RevisionCardActivityPresenter @Inject constructor( private val activity: AppCompatActivity, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, private val topicController: TopicController, private val translationController: TranslationController ) { @@ -62,7 +64,7 @@ class RevisionCardActivityPresenter @Inject constructor( activity.supportActionBar?.setDisplayShowTitleEnabled(false) binding.revisionCardToolbar.setNavigationOnClickListener { - (activity as RevisionCardActivity).finish() + (activity as ReturnToTopicClickListener).onReturnToTopicRequested() } binding.revisionCardToolbarTitle.setOnClickListener { binding.revisionCardToolbarTitle.isSelected = true @@ -106,6 +108,13 @@ class RevisionCardActivityPresenter @Inject constructor( /** Dismisses the concept card fragment if it's currently active in this activity. */ fun dismissConceptCard() = getReviewCardFragment()?.dismissConceptCard() + fun logExitRevisionCard() { + analyticsController.logImportantEvent( + oppiaLogger.createCloseRevisionCardContext(topicId, subtopicId), + profileId + ) + } + private fun subscribeToSubtopicTitle() { subtopicLiveData.observe( activity, diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentPresenter.kt index 57931980e48..266645ae3fc 100755 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentPresenter.kt @@ -11,6 +11,7 @@ import org.oppia.android.app.topic.conceptcard.ConceptCardFragment.Companion.CON import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.RevisionCardFragmentBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.parser.html.HtmlParser @@ -22,6 +23,7 @@ import javax.inject.Inject class RevisionCardFragmentPresenter @Inject constructor( private val fragment: Fragment, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, private val htmlParserFactory: HtmlParser.Factory, @DefaultResourceBucketName private val resourceBucketName: String, @TopicHtmlParserEntityType private val entityType: String, @@ -92,7 +94,10 @@ class RevisionCardFragmentPresenter @Inject constructor( } private fun logRevisionCardEvent(topicId: String, subTopicId: Int) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenRevisionCardContext(topicId, subTopicId)) + analyticsController.logImportantEvent( + oppiaLogger.createOpenRevisionCardContext(topicId, subTopicId), + profileId + ) } override fun onConceptCardLinkClicked(view: View, skillId: String) { diff --git a/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel b/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel index e5bc20b3d81..0e6bf561ab0 100644 --- a/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel +++ b/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel @@ -29,6 +29,7 @@ kt_android_library( deps = [ ":app_language_locale_handler", ":dagger", + "//model/src/main/proto:profile_java_proto_lite", "//third_party:androidx_appcompat_appcompat", "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale", ], diff --git a/app/src/main/res/drawable/start_over_button_background.xml b/app/src/main/res/drawable/secondary_button_background.xml similarity index 78% rename from app/src/main/res/drawable/start_over_button_background.xml rename to app/src/main/res/drawable/secondary_button_background.xml index b06f4205776..2debad327a0 100644 --- a/app/src/main/res/drawable/start_over_button_background.xml +++ b/app/src/main/res/drawable/secondary_button_background.xml @@ -5,5 +5,5 @@ + android:color="@color/component_color_shared_secondary_button_background_trim_color" /> diff --git a/app/src/main/res/layout-land/profile_edit_fragment.xml b/app/src/main/res/layout-land/profile_edit_fragment.xml index 65703ff695e..09ea627140b 100644 --- a/app/src/main/res/layout-land/profile_edit_fragment.xml +++ b/app/src/main/res/layout-land/profile_edit_fragment.xml @@ -111,6 +111,87 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/profile_rename_button" /> +