diff --git a/.github/workflows/validate-english-strings.yml b/.github/workflows/validate-english-strings.yml new file mode 100644 index 000000000..43935b05e --- /dev/null +++ b/.github/workflows/validate-english-strings.yml @@ -0,0 +1,32 @@ +name: Validate English strings.xml + +on: + pull_request: { } + push: + branches: [ main, develop ] + +jobs: + translation_strings: + name: Validate strings.xml + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Install translations requirements + run: make translation_requirements + + - name: Validate English plurals in strings.xml + run: make validate_english_plurals + + - name: Test extract strings + run: | + make extract_translations + # Ensure the file is extracted + test -f i18n/src/main/res/values/strings.xml diff --git a/.gitignore b/.gitignore index 1cc8ec083..1152644c7 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ local.properties /.idea/ *.log /config_settings.yaml +.venv/ +i18n/ +**/values-*/strings.xml diff --git a/Documentation/ConfigurationManagement.md b/Documentation/ConfigurationManagement.md index b1e21a50b..c3786b1d6 100644 --- a/Documentation/ConfigurationManagement.md +++ b/Documentation/ConfigurationManagement.md @@ -88,7 +88,7 @@ android: - **PRE_LOGIN_EXPERIENCE_ENABLED:** Enables the pre login courses discovery experience. - **WHATS_NEW_ENABLED:** Enables the "What's New" feature to present the latest changes to the user. - **SOCIAL_AUTH_ENABLED:** Enables SSO buttons on the SignIn and SignUp screens. -- **COURSE_NESTED_LIST_ENABLED:** Enables an alternative visual representation for the course structure. +- **COURSE_DROPDOWN_NAVIGATION_ENABLED:** Enables an alternative navigation through units. - **COURSE_UNIT_PROGRESS_ENABLED:** Enables the display of the unit progress within the courseware. ## Future Support diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..a0ba67b45 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +clean_translations_temp_directory: + rm -rf i18n/ + +translation_requirements: + pip3 install -r i18n_scripts/requirements.txt + +pull_translations: clean_translations_temp_directory + atlas pull $(ATLAS_OPTIONS) translations/openedx-app-android/i18n:i18n + python3 i18n_scripts/translation.py --split --replace-underscore + +extract_translations: clean_translations_temp_directory + python3 i18n_scripts/translation.py --combine + +validate_english_plurals: + @if git grep 'quantity' -- '**/res/values/strings.xml' | grep -E 'quantity=.(zero|two|few|many)'; then \ + echo ""; \ + echo ""; \ + echo "Error: Found invalid plurals in the files listed above."; \ + echo " Please only use 'one' and 'other' in English strings.xml files,"; \ + echo " otherwise Transifex fails to parse them."; \ + echo ""; \ + exit 1; \ + else \ + echo "strings.xml files are valid."; \ + fi diff --git a/README.md b/README.md index c8453877a..65a19cce7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,47 @@ Modern vision of the mobile application for the Open edX platform from Raccoon G 6. Click the **Run** button. +## Translations + +### Getting Translations for the App +Translations aren't included in the source code of this repository as of [OEP-58](https://docs.openedx.org/en/latest/developers/concepts/oep58.html). Therefore, they need to be pulled before testing or publishing to App Store. + +Before retrieving the translations for the app, we need to install the requirements listed in the requirements.txt file located in the i18n_scripts directory. This can be done easily by running the following make command: +```bash +make translation_requirements +``` + +Then, to get the latest translations for all languages use the following command: +```bash +make pull_translations +``` +This command runs [`atlas pull`](https://github.com/openedx/openedx-atlas) to download the latest translations files from the [openedx/openedx-translations](https://github.com/openedx/openedx-translations) repository. These files contain the latest translations for all languages. In the [openedx/openedx-translations](https://github.com/openedx/openedx-translations) repository each language's translations are saved as a single file e.g. `i18n/src/main/res/values-uk/strings.xml` ([example](https://github.com/openedx/openedx-translations/blob/04ccea36b8e6a9889646dfb5a5acb99686fa9ae0/translations/openedx-app-android/i18n/src/main/res/values-uk/strings.xml)). After these are pulled, each language's translation file is split into the App's modules e.g. `auth/src/main/res/values-uk/strings.xml`. + + After this command is run the application can load the translations by changing the device (or the emulator) language in the settings. + +### Using Custom Translations + +By default, the command `make pull_translations` runs [`atlas pull`](https://github.com/openedx/openedx-atlas) with no arguments which pulls translations from the [openedx-translations repository](https://github.com/openedx/openedx-translations). + +You can use custom translations on your fork of the openedx-translations repository by setting the following configuration parameters: + +- `--revision` (default: `"main"`): Branch or git tag to pull translations from. +- `--repository` (default: `"openedx/openedx-translations"`): GitHub repository slug. There's a feature request to [support GitLab and other providers](https://github.com/openedx/openedx-atlas/issues/20). + +Arguments can be passed via the `ATLAS_OPTIONS` environment variable as shown below: +``` bash +make ATLAS_OPTIONS='--repository=/ --revision=' pull_translations +``` +Additional arguments can be passed to `atlas pull`. Refer to the [atlas documentations ](https://github.com/openedx/openedx-atlas) for more information. + +### How to Translate the App + +Translations are managed in the [open-edx/openedx-translations](https://app.transifex.com/open-edx/openedx-translations/dashboard/) Transifex project. + +To translate the app join the [Transifex project](https://app.transifex.com/open-edx/openedx-translations/dashboard/) and add your translations `openedx-app-android` resource: https://app.transifex.com/open-edx/openedx-translations/openedx-app-android/ (the link will start working after the [pull request #317](https://github.com/openedx/openedx-app-android/pull/317) is merged) + +Once the resource is both 100% translated and reviewed the [Transifex integration](https://github.com/apps/transifex-integration) will automatically push it to the [openedx-translations](https://github.com/openedx/openedx-translations) repository and developers can use the translations in their app. + ## API This project targets on the latest Open edX release and rely on the relevant mobile APIs. diff --git a/app/build.gradle b/app/build.gradle index 2b0ab4f74..5bf5182e7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,13 +1,20 @@ def config = configHelper.fetchConfig() def appId = config.getOrDefault("APPLICATION_ID", "org.openedx.app") -def platformName = config.getOrDefault("PLATFORM_NAME", "OpenEdx").toLowerCase() +def themeDirectory = config.getOrDefault("THEME_DIRECTORY", "openedx") def firebaseConfig = config.get('FIREBASE') def firebaseEnabled = firebaseConfig?.getOrDefault('ENABLED', false) +def fullstoryConfig = config.get("FULLSTORY") +def fullstoryEnabled = fullstoryConfig?.getOrDefault('ENABLED', false) apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-kapt' + +if (fullstoryEnabled) { + apply plugin: 'fullstory' +} + if (firebaseEnabled) { apply plugin: 'com.google.gms.google-services' apply plugin: 'com.google.firebase.crashlytics' @@ -25,6 +32,18 @@ if (firebaseEnabled) { preBuild.dependsOn(removeGoogleServicesJson) } +if (fullstoryEnabled) { + def fullstoryOrgId = fullstoryConfig?.get("ORG_ID") + + fullstory { + org fullstoryOrgId + composeEnabled true + composeSelectorVersion 4 + enabledVariants 'debug|release' + logcatLevel 'error' + } +} + android { compileSdk 34 @@ -63,13 +82,13 @@ android { sourceSets { prod { - res.srcDirs = ["src/$platformName/res"] + res.srcDirs = ["src/$themeDirectory/res"] } develop { - res.srcDirs = ["src/$platformName/res"] + res.srcDirs = ["src/$themeDirectory/res"] } stage { - res.srcDirs = ["src/$platformName/res"] + res.srcDirs = ["src/$themeDirectory/res"] } } @@ -130,6 +149,9 @@ dependencies { implementation 'androidx.core:core-splashscreen:1.0.1' + api platform("com.google.firebase:firebase-bom:$firebase_version") + api "com.google.firebase:firebase-messaging" + // Segment Library implementation "com.segment.analytics.kotlin:android:1.14.2" // Segment's Firebase integration @@ -138,14 +160,6 @@ dependencies { implementation "com.braze:braze-segment-kotlin:1.4.2" implementation "com.braze:android-sdk-ui:30.2.0" - // Firebase Cloud Messaging Integration for Braze - implementation 'com.google.firebase:firebase-messaging-ktx:23.4.1' - - // Branch SDK Integration - implementation 'io.branch.sdk.android:library:5.9.0' - implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1' - implementation "com.android.installreferrer:installreferrer:2.2" - androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" diff --git a/app/src/edx/res/drawable/ic_launcher_foreground.xml b/app/src/edx/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..f542143cc --- /dev/null +++ b/app/src/edx/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/edx/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/edx/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6b78462d6 --- /dev/null +++ b/app/src/edx/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/edx/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/edx/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6b78462d6 --- /dev/null +++ b/app/src/edx/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/edx/res/mipmap-hdpi/ic_launcher.png b/app/src/edx/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..3bcadcfdf Binary files /dev/null and b/app/src/edx/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/edx/res/mipmap-mdpi/ic_launcher.png b/app/src/edx/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..4184318fd Binary files /dev/null and b/app/src/edx/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/edx/res/mipmap-xhdpi/ic_launcher.png b/app/src/edx/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..683b4aa56 Binary files /dev/null and b/app/src/edx/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/edx/res/mipmap-xxhdpi/ic_launcher.png b/app/src/edx/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..f6467b999 Binary files /dev/null and b/app/src/edx/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/edx/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/edx/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..c82b65553 Binary files /dev/null and b/app/src/edx/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8020f6b74..a3921ac64 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + @@ -39,7 +41,8 @@ android:exported="true" android:fitsSystemWindows="true" android:theme="@style/Theme.App.Starting" - android:windowSoftInputMode="adjustPan"> + android:windowSoftInputMode="adjustPan" + android:launchMode="singleInstance"> @@ -102,9 +105,9 @@ android:foregroundServiceType="dataSync" tools:node="merge" /> - + diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 356a23459..b0adbcb22 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -3,10 +3,12 @@ package org.openedx.app import android.content.Context import org.openedx.app.analytics.Analytics import org.openedx.app.analytics.FirebaseAnalytics +import org.openedx.app.analytics.FullstoryAnalytics import org.openedx.app.analytics.SegmentAnalytics import org.openedx.auth.presentation.AuthAnalytics import org.openedx.core.config.Config import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.IAPAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.course.presentation.CourseAnalytics import org.openedx.dashboard.presentation.DashboardAnalytics @@ -20,7 +22,7 @@ class AnalyticsManager( config: Config, ) : AppAnalytics, AppReviewAnalytics, AuthAnalytics, CoreAnalytics, CourseAnalytics, DashboardAnalytics, DiscoveryAnalytics, DiscussionAnalytics, ProfileAnalytics, - WhatsNewAnalytics { + WhatsNewAnalytics, IAPAnalytics { private val services: ArrayList = arrayListOf() @@ -29,10 +31,15 @@ class AnalyticsManager( if (config.getFirebaseConfig().enabled) { addAnalyticsTracker(FirebaseAnalytics(context = context)) } + val segmentConfig = config.getSegmentConfig() if (segmentConfig.enabled && segmentConfig.segmentWriteKey.isNotBlank()) { addAnalyticsTracker(SegmentAnalytics(context = context, config = config)) } + + if (config.getFullstoryConfig().isEnabled) { + addAnalyticsTracker(FullstoryAnalytics()) + } } private fun addAnalyticsTracker(analytic: Analytics) { @@ -45,6 +52,12 @@ class AnalyticsManager( } } + override fun logScreenEvent(screenName: String, params: Map) { + services.forEach { analytics -> + analytics.logScreenEvent(screenName, params) + } + } + override fun logEvent(event: String, params: Map) { services.forEach { analytics -> analytics.logEvent(event, params) diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 5ab0d0b0e..e03d9f2cd 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -13,11 +13,13 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.window.layout.WindowMetricsCalculator +import com.braze.support.toStringMap import io.branch.referral.Branch import io.branch.referral.Branch.BranchUniversalReferralInitListener import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.ActivityAppBinding +import org.openedx.app.deeplink.DeepLink import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.data.storage.CorePreferences @@ -57,6 +59,20 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private var _windowSize = WindowSize(WindowType.Compact, WindowType.Compact) + private val branchCallback = + BranchUniversalReferralInitListener { branchUniversalObject, _, error -> + if (branchUniversalObject?.contentMetadata?.customMetadata != null) { + branchLogger.i { "Branch init complete." } + branchLogger.i { branchUniversalObject.contentMetadata.customMetadata.toString() } + viewModel.makeExternalRoute( + fm = supportFragmentManager, + deepLink = DeepLink(branchUniversalObject.contentMetadata.customMetadata) + ) + } else if (error != null) { + branchLogger.e { "Branch init failed. Caused by -" + error.message } + } + } + override fun onSaveInstanceState(outState: Bundle) { outState.putInt(TOP_INSET, topInset) outState.putInt(BOTTOM_INSET, bottomInset) @@ -134,6 +150,11 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { addFragment(MainFragment.newInstance()) } } + + val extras = intent.extras + if (extras?.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) == true) { + handlePushNotification(extras) + } } viewModel.logoutUser.observe(this) { @@ -145,17 +166,8 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { super.onStart() if (viewModel.isBranchEnabled) { - val callback = BranchUniversalReferralInitListener { _, linkProperties, error -> - if (linkProperties != null) { - branchLogger.i { "Branch init complete." } - branchLogger.i { linkProperties.controlParams.toString() } - } else if (error != null) { - branchLogger.e { "Branch init failed. Caused by -" + error.message } - } - } - Branch.sessionBuilder(this) - .withCallback(callback) + .withCallback(branchCallback) .withData(this.intent.data) .init() } @@ -165,15 +177,16 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { super.onNewIntent(intent) this.intent = intent + val extras = intent?.extras + if (extras?.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) == true) { + handlePushNotification(extras) + } + if (viewModel.isBranchEnabled) { if (intent?.getBooleanExtra(BRANCH_FORCE_NEW_SESSION, false) == true) { - Branch.sessionBuilder(this).withCallback { referringParams, error -> - if (error != null) { - branchLogger.e { error.message } - } else if (referringParams != null) { - branchLogger.i { referringParams.toString() } - } - }.reInit() + Branch.sessionBuilder(this) + .withCallback(branchCallback) + .reInit() } } } @@ -213,6 +226,11 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { } } + private fun handlePushNotification(data: Bundle) { + val deepLink = DeepLink(data.toStringMap()) + viewModel.makeExternalRoute(supportFragmentManager, deepLink) + } + companion object { const val TOP_INSET = "topInset" const val BOTTOM_INSET = "bottomInset" diff --git a/app/src/main/java/org/openedx/app/AppAnalytics.kt b/app/src/main/java/org/openedx/app/AppAnalytics.kt index 51278ef13..a122e79c1 100644 --- a/app/src/main/java/org/openedx/app/AppAnalytics.kt +++ b/app/src/main/java/org/openedx/app/AppAnalytics.kt @@ -4,6 +4,7 @@ interface AppAnalytics { fun logoutEvent(force: Boolean) fun setUserIdForSession(userId: Long) fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) } enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { @@ -15,14 +16,6 @@ enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { "MainDashboard:Discover", "edx.bi.app.main_dashboard.discover" ), - MY_COURSES( - "MainDashboard:My Courses", - "edx.bi.app.main_dashboard.my_course" - ), - MY_PROGRAMS( - "MainDashboard:My Programs", - "edx.bi.app.main_dashboard.my_program" - ), PROFILE( "MainDashboard:Profile", "edx.bi.app.main_dashboard.profile" diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 21f3b5aee..99eb919dc 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -13,8 +13,8 @@ import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.webview.WebContentFragment -import org.openedx.core.presentation.settings.VideoQualityFragment -import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.presentation.settings.video.VideoQualityFragment +import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.container.NoAccessCourseContainerFragment @@ -25,6 +25,7 @@ import org.openedx.course.presentation.unit.container.CourseUnitContainerFragmen import org.openedx.course.presentation.unit.video.VideoFullScreenFragment import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment import org.openedx.course.settings.download.DownloadQueueFragment +import org.openedx.courses.presentation.AllEnrolledCoursesFragment import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.NativeDiscoveryFragment @@ -44,6 +45,7 @@ import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.anothersaccount.AnothersProfileFragment +import org.openedx.profile.presentation.calendar.CalendarFragment import org.openedx.profile.presentation.delete.DeleteProfileFragment import org.openedx.profile.presentation.edit.EditProfileFragment import org.openedx.profile.presentation.manageaccount.ManageAccountFragment @@ -57,10 +59,15 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ProfileRouter, AppUpgradeRouter, WhatsNewRouter { //region AuthRouter - override fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) { + override fun navigateToMain( + fm: FragmentManager, + courseId: String?, + infoType: String?, + openTab: String + ) { fm.popBackStack() fm.beginTransaction() - .replace(R.id.container, MainFragment.newInstance(courseId, infoType)) + .replace(R.id.container, MainFragment.newInstance(courseId, infoType, openTab)) .commit() } @@ -122,6 +129,14 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, UpgradeRequiredFragment()) } + override fun navigateToAllEnrolledCourses(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, AllEnrolledCoursesFragment()) + } + + override fun getProgramFragment(): Fragment { + return ProgramFragment.newInstance(isNestedFragment = true) + } + override fun navigateToCourseInfo( fm: FragmentManager, courseId: String, @@ -129,6 +144,18 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ) { replaceFragmentWithBackStack(fm, CourseInfoFragment.newInstance(courseId, infoType)) } + + override fun navigateToCourseOutline( + fm: FragmentManager, + courseId: String, + courseTitle: String, + enrollmentMode: String, + ) { + replaceFragmentWithBackStack( + fm, + CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode) + ) + } //endregion //region DashboardRouter @@ -138,20 +165,31 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, courseTitle: String, enrollmentMode: String, + openTab: String, + resumeBlockId: String, ) { replaceFragmentWithBackStack( fm, - CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode) + CourseContainerFragment.newInstance( + courseId, + courseTitle, + enrollmentMode, + openTab, + resumeBlockId + ) ) } override fun navigateToEnrolledProgramInfo(fm: FragmentManager, pathId: String) { - replaceFragmentWithBackStack(fm, ProgramFragment.newInstance(pathId)) + replaceFragmentWithBackStack( + fm, + ProgramFragment.newInstance(pathId = pathId, isNestedFragment = false) + ) } override fun navigateToNoAccess( fm: FragmentManager, - title: String + title: String, ) { replaceFragment(fm, NoAccessCourseContainerFragment.newInstance(title)) } @@ -165,7 +203,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di subSectionId: String, unitId: String, componentId: String, - mode: CourseViewMode + mode: CourseViewMode, ) { replaceFragmentWithBackStack( fm, @@ -184,7 +222,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, unitId: String, componentId: String, - mode: CourseViewMode + mode: CourseViewMode, ) { replaceFragmentWithBackStack( fm, @@ -202,7 +240,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, unitId: String, componentId: String, - mode: CourseViewMode + mode: CourseViewMode, ) { replaceFragment( fm, @@ -222,7 +260,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di videoTime: Long, blockId: String, courseId: String, - isPlaying: Boolean + isPlaying: Boolean, ) { replaceFragmentWithBackStack( fm, @@ -236,7 +274,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di videoTime: Long, blockId: String, courseId: String, - isPlaying: Boolean + isPlaying: Boolean, ) { replaceFragmentWithBackStack( fm, @@ -253,12 +291,11 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToHandoutsWebView( fm: FragmentManager, courseId: String, - title: String, - type: HandoutsType + type: HandoutsType, ) { replaceFragmentWithBackStack( fm, - HandoutsWebViewFragment.newInstance(title, type.name, courseId) + HandoutsWebViewFragment.newInstance(type.name, courseId) ) } //endregion @@ -270,7 +307,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, topicId: String, title: String, - viewType: FragmentViewType + viewType: FragmentViewType, ) { replaceFragmentWithBackStack( fm, @@ -288,7 +325,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToDiscussionResponses( fm: FragmentManager, comment: DiscussionComment, - isClosed: Boolean + isClosed: Boolean, ) { replaceFragmentWithBackStack( fm, @@ -316,7 +353,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToAnothersProfile( fm: FragmentManager, - username: String + username: String, ) { replaceFragmentWithBackStack( fm, @@ -370,8 +407,16 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToManageAccount(fm: FragmentManager) { replaceFragmentWithBackStack(fm, ManageAccountFragment()) } + + override fun navigateToCalendarSettings(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, CalendarFragment()) + } //endregion + fun getVisibleFragment(fm: FragmentManager): Fragment? { + return fm.fragments.firstOrNull { it.isVisible } + } + private fun replaceFragmentWithBackStack(fm: FragmentManager, fragment: Fragment) { fm.beginTransaction() .replace(R.id.container, fragment, fragment.javaClass.simpleName) @@ -382,7 +427,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di private fun replaceFragment( fm: FragmentManager, fragment: Fragment, - transaction: Int = FragmentTransaction.TRANSIT_NONE + transaction: Int = FragmentTransaction.TRANSIT_NONE, ) { fm.beginTransaction() .setTransition(transaction) diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 1febbd15a..20b3b0c97 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -1,5 +1,9 @@ package org.openedx.app +import android.annotation.SuppressLint +import android.app.NotificationManager +import android.content.Context +import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope @@ -7,13 +11,22 @@ import androidx.room.RoomDatabase import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.system.notifier.LogoutEvent +import org.openedx.app.deeplink.DeepLink +import org.openedx.app.deeplink.DeepLinkRouter +import org.openedx.app.system.push.RefreshFirebaseTokenWorker +import org.openedx.app.system.push.SyncFirebaseTokenWorker import org.openedx.core.BaseViewModel import org.openedx.core.SingleEventLiveData import org.openedx.core.config.Config +import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.LogoutEvent +import org.openedx.core.system.notifier.app.SignInEvent +import org.openedx.core.utils.FileUtil + +@SuppressLint("StaticFieldLeak") class AppViewModel( private val config: Config, private val notifier: AppNotifier, @@ -21,6 +34,9 @@ class AppViewModel( private val preferencesManager: CorePreferences, private val dispatcher: CoroutineDispatcher, private val analytics: AppAnalytics, + private val deepLinkRouter: DeepLinkRouter, + private val fileUtil: FileUtil, + private val context: Context ) : BaseViewModel() { private val _logoutUser = SingleEventLiveData() @@ -32,20 +48,29 @@ class AppViewModel( private var logoutHandledAt: Long = 0 val isBranchEnabled get() = config.getBranchConfig().enabled + private val canResetAppDirectory get() = preferencesManager.canResetAppDirectory override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - setUserId() + + val user = preferencesManager.user + + setUserId(user) + + if (user != null && preferencesManager.pushToken.isNotEmpty()) { + SyncFirebaseTokenWorker.schedule(context) + } + + if (canResetAppDirectory) { + resetAppDirectory() + } + viewModelScope.launch { notifier.notifier.collect { event -> - if (event is LogoutEvent && System.currentTimeMillis() - logoutHandledAt > 5000) { - logoutHandledAt = System.currentTimeMillis() - preferencesManager.clear() - withContext(dispatcher) { - room.clearAllTables() - } - analytics.logoutEvent(true) - _logoutUser.value = Unit + if (event is SignInEvent && config.getFirebaseConfig().isCloudMessagingEnabled) { + SyncFirebaseTokenWorker.schedule(context) + } else if (event is LogoutEvent) { + handleLogoutEvent(event) } } } @@ -60,9 +85,39 @@ class AppViewModel( ) } - private fun setUserId() { - preferencesManager.user?.let { + private fun resetAppDirectory() { + fileUtil.deleteOldAppDirectory() + preferencesManager.canResetAppDirectory = false + } + + fun makeExternalRoute(fm: FragmentManager, deepLink: DeepLink) { + deepLinkRouter.makeRoute(fm, deepLink) + } + + private fun setUserId(user: User?) { + user?.let { analytics.setUserIdForSession(it.id) } } + + private suspend fun handleLogoutEvent(event: LogoutEvent) { + if (System.currentTimeMillis() - logoutHandledAt > 5000) { + if (event.isForced) { + logoutHandledAt = System.currentTimeMillis() + preferencesManager.clear() + withContext(dispatcher) { + room.clearAllTables() + } + analytics.logoutEvent(true) + _logoutUser.value = Unit + } + + if (config.getFirebaseConfig().isCloudMessagingEnabled) { + RefreshFirebaseTokenWorker.schedule(context) + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancelAll() + } + } + } } diff --git a/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt b/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt deleted file mode 100644 index d8ca717d4..000000000 --- a/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.openedx.app - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.fragment.app.Fragment -import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appTypography - -class InDevelopmentFragment : Fragment() { - - @OptIn(ExperimentalComposeUiApi::class) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - Scaffold( - modifier = Modifier.semantics { - testTagsAsResourceId = true - }, - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(it) - .background(MaterialTheme.appColors.secondary), - contentAlignment = Alignment.Center - ) { - Text( - modifier = Modifier.testTag("txt_in_development"), - text = "Will be available soon", - style = MaterialTheme.appTypography.headlineMedium - ) - } - } - } - } -} diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index a798c4a3f..4011b3a04 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -11,15 +11,14 @@ import androidx.viewpager2.widget.ViewPager2 import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.app.adapter.MainNavigationFragmentAdapter import org.openedx.app.databinding.FragmentMainBinding -import org.openedx.core.config.Config +import org.openedx.app.deeplink.HomeTab +import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding -import org.openedx.dashboard.presentation.DashboardFragment -import org.openedx.discovery.presentation.DiscoveryNavigator import org.openedx.discovery.presentation.DiscoveryRouter -import org.openedx.discovery.presentation.program.ProgramFragment +import org.openedx.learn.presentation.LearnFragment +import org.openedx.learn.presentation.LearnTab import org.openedx.profile.presentation.profile.ProfileFragment class MainFragment : Fragment(R.layout.fragment_main) { @@ -27,9 +26,8 @@ class MainFragment : Fragment(R.layout.fragment_main) { private val binding by viewBinding(FragmentMainBinding::bind) private val viewModel by viewModel() private val router by inject() - private val config by inject() - private lateinit var adapter: MainNavigationFragmentAdapter + private lateinit var adapter: NavigationFragmentAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -47,30 +45,22 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.bottomNavView.setOnItemSelectedListener { when (it.itemId) { - R.id.fragmentHome -> { - viewModel.logDiscoveryTabClickedEvent() + R.id.fragmentLearn -> { binding.viewPager.setCurrentItem(0, false) } - R.id.fragmentDashboard -> { - viewModel.logMyCoursesTabClickedEvent() + R.id.fragmentDiscover -> { + viewModel.logDiscoveryTabClickedEvent() binding.viewPager.setCurrentItem(1, false) } - R.id.fragmentPrograms -> { - viewModel.logMyProgramsTabClickedEvent() - binding.viewPager.setCurrentItem(2, false) - } - R.id.fragmentProfile -> { viewModel.logProfileTabClickedEvent() - binding.viewPager.setCurrentItem(3, false) + binding.viewPager.setCurrentItem(2, false) } } true } - // Trigger click event for the first tab on initial load - binding.bottomNavView.selectedItemId = binding.bottomNavView.selectedItemId viewModel.isBottomBarEnabled.observe(viewLifecycleOwner) { isBottomBarEnabled -> enableBottomBar(isBottomBarEnabled) @@ -79,7 +69,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { viewLifecycleOwner.lifecycleScope.launch { viewModel.navigateToDiscovery.collect { shouldNavigateToDiscovery -> if (shouldNavigateToDiscovery) { - binding.bottomNavView.selectedItemId = R.id.fragmentHome + binding.bottomNavView.selectedItemId = R.id.fragmentDiscover } } } @@ -88,7 +78,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> val infoType = getString(ARG_INFO_TYPE) - if (config.getDiscoveryConfig().isViewTypeWebView() && infoType != null) { + if (viewModel.isDiscoveryTypeWebView && infoType != null) { router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) } else { router.navigateToCourseDetail(parentFragmentManager, courseId) @@ -98,6 +88,22 @@ class MainFragment : Fragment(R.layout.fragment_main) { putString(ARG_COURSE_ID, "") putString(ARG_INFO_TYPE, "") } + + when (requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name)) { + HomeTab.LEARN.name, + HomeTab.PROGRAMS.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentLearn + } + + HomeTab.DISCOVER.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentDiscover + } + + HomeTab.PROFILE.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentProfile + } + } + requireArguments().remove(ARG_OPEN_TAB) } } @@ -105,18 +111,15 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL binding.viewPager.offscreenPageLimit = 4 - val discoveryFragment = DiscoveryNavigator(viewModel.isDiscoveryTypeWebView) - .getDiscoveryFragment() - val programFragment = if (viewModel.isProgramTypeWebView) { - ProgramFragment(true) + val openTab = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) + val learnTab = if (openTab == HomeTab.PROGRAMS.name) { + LearnTab.PROGRAMS } else { - InDevelopmentFragment() + LearnTab.COURSES } - - adapter = MainNavigationFragmentAdapter(this).apply { - addFragment(discoveryFragment) - addFragment(DashboardFragment()) - addFragment(programFragment) + adapter = NavigationFragmentAdapter(this).apply { + addFragment(LearnFragment.newInstance(openTab = learnTab.name)) + addFragment(viewModel.getDiscoveryFragment) addFragment(ProfileFragment()) } binding.viewPager.adapter = adapter @@ -132,11 +135,17 @@ class MainFragment : Fragment(R.layout.fragment_main) { companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_INFO_TYPE = "info_type" - fun newInstance(courseId: String? = null, infoType: String? = null): MainFragment { + private const val ARG_OPEN_TAB = "open_tab" + fun newInstance( + courseId: String? = null, + infoType: String? = null, + openTab: String = HomeTab.LEARN.name + ): MainFragment { val fragment = MainFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, - ARG_INFO_TYPE to infoType + ARG_INFO_TYPE to infoType, + ARG_OPEN_TAB to openTab ) return fragment } diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 6a30533ea..5cef29361 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -14,6 +14,7 @@ import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.NavigationToDiscovery +import org.openedx.discovery.presentation.DiscoveryNavigator class MainViewModel( private val config: Config, @@ -30,16 +31,18 @@ class MainViewModel( get() = _navigateToDiscovery.asSharedFlow() val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() - - val isProgramTypeWebView get() = config.getProgramConfig().isViewTypeWebView() + val getDiscoveryFragment get() = DiscoveryNavigator(isDiscoveryTypeWebView).getDiscoveryFragment() override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - notifier.notifier.onEach { - if (it is NavigationToDiscovery) { - _navigateToDiscovery.emit(true) + notifier.notifier + .onEach { + if (it is NavigationToDiscovery) { + _navigateToDiscovery.emit(true) + } } - }.distinctUntilChanged().launchIn(viewModelScope) + .distinctUntilChanged() + .launchIn(viewModelScope) } fun enableBottomBar(enable: Boolean) { @@ -47,24 +50,17 @@ class MainViewModel( } fun logDiscoveryTabClickedEvent() { - logEvent(AppAnalyticsEvent.DISCOVER) - } - - fun logMyCoursesTabClickedEvent() { - logEvent(AppAnalyticsEvent.MY_COURSES) - } - - fun logMyProgramsTabClickedEvent() { - logEvent(AppAnalyticsEvent.MY_PROGRAMS) + logScreenEvent(AppAnalyticsEvent.DISCOVER) } fun logProfileTabClickedEvent() { - logEvent(AppAnalyticsEvent.PROFILE) + logScreenEvent(AppAnalyticsEvent.PROFILE) } - private fun logEvent(event: AppAnalyticsEvent) { - analytics.logEvent(event.eventName, - buildMap { + private fun logScreenEvent(event: AppAnalyticsEvent) { + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { put(AppAnalyticsKey.NAME.key, event.biValue) } ) diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 7d1b81d32..ccf20d5b2 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -3,11 +3,13 @@ package org.openedx.app import android.app.Application import com.braze.Braze import com.braze.configuration.BrazeConfig +import com.braze.ui.BrazeDeeplinkHandler import com.google.firebase.FirebaseApp import io.branch.referral.Branch import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin +import org.openedx.app.deeplink.BranchBrazeDeeplinkHandler import org.openedx.app.di.appModule import org.openedx.app.di.networkingModule import org.openedx.app.di.screenModule @@ -36,6 +38,7 @@ class OpenEdXApp : Application() { Branch.enableTestMode() Branch.enableLogging() } + Branch.expectDelayedSessionInitialization(true) Branch.getAutoInstance(this) } @@ -50,6 +53,10 @@ class OpenEdXApp : Application() { .setIsFirebaseMessagingServiceOnNewTokenRegistrationEnabled(true) .build() Braze.configure(this, brazeConfig) + + if (config.getBranchConfig().enabled) { + BrazeDeeplinkHandler.setBrazeDeeplinkHandler(BranchBrazeDeeplinkHandler()) + } } } } diff --git a/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt index 503f3d1ef..17d3b3b62 100644 --- a/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt +++ b/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt @@ -16,6 +16,7 @@ class FirebaseAnalytics(context: Context) : Analytics { } override fun logScreenEvent(screenName: String, params: Map) { + tracker.logEvent(screenName, params.toBundle()) logger.d { "Firebase Analytics log Screen Event: $screenName + $params" } } diff --git a/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt new file mode 100644 index 000000000..11aa26bc7 --- /dev/null +++ b/app/src/main/java/org/openedx/app/analytics/FullstoryAnalytics.kt @@ -0,0 +1,41 @@ +package org.openedx.app.analytics + +import com.fullstory.FS +import com.fullstory.FSSessionData +import org.openedx.core.utils.Logger + +class FullstoryAnalytics : Analytics { + + private val logger = Logger(TAG) + + init { + FS.setReadyListener { sessionData: FSSessionData -> + val sessionUrl = sessionData.currentSessionURL + logger.d { "FullStory Session URL is: $sessionUrl" } + } + } + + override fun logScreenEvent(screenName: String, params: Map) { + logger.d { "Page : $screenName $params" } + FS.page(screenName, params).start() + } + + override fun logEvent(eventName: String, params: Map) { + logger.d { "Event: $eventName $params" } + FS.page(eventName, params).start() + } + + override fun logUserId(userId: Long) { + logger.d { "Identify: $userId" } + FS.identify( + userId.toString(), mapOf( + DISPLAY_NAME to userId + ) + ) + } + + private companion object { + const val TAG = "FullstoryAnalytics" + private const val DISPLAY_NAME = "displayName" + } +} diff --git a/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt b/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt new file mode 100644 index 000000000..9106944c3 --- /dev/null +++ b/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt @@ -0,0 +1,14 @@ +package org.openedx.app.data.api + +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +interface NotificationsApi { + @POST("/api/mobile/v4/notifications/create-token/") + @FormUrlEncoded + suspend fun syncFirebaseToken( + @Field("registration_id") token: String, + @Field("active") active: Boolean = true + ) +} diff --git a/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt index 4e88eec42..abf90d7a2 100644 --- a/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt @@ -4,13 +4,13 @@ import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response import org.openedx.app.BuildConfig -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.utils.TimeUtils import java.util.Date class AppUpgradeInterceptor( - private val appUpgradeNotifier: AppUpgradeNotifier + private val appNotifier: AppNotifier ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val response = chain.proceed(chain.request()) @@ -21,15 +21,15 @@ class AppUpgradeInterceptor( runBlocking { when { responseCode == 426 -> { - appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + appNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) } BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime > Date().time -> { - appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion)) + appNotifier.send(AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion)) } latestAppVersion.isNotEmpty() && BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime < Date().time -> { - appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + appNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) } } } diff --git a/app/src/main/java/org/openedx/app/data/networking/HandleErrorInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HandleErrorInterceptor.kt index bd4aa1920..a4145d549 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HandleErrorInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HandleErrorInterceptor.kt @@ -2,11 +2,11 @@ package org.openedx.app.data.networking import com.google.gson.Gson import com.google.gson.JsonSyntaxException -import org.openedx.core.data.model.ErrorResponse -import org.openedx.core.system.EdxError import okhttp3.Interceptor import okhttp3.Response import okio.IOException +import org.openedx.core.data.model.ErrorResponse +import org.openedx.core.system.EdxError class HandleErrorInterceptor( private val gson: Gson @@ -16,7 +16,7 @@ class HandleErrorInterceptor( val responseCode = response.code if (responseCode in 400..500 && response.body != null) { - val jsonStr = response.body!!.string() + val jsonStr = response.peekBody(Long.MAX_VALUE).string() try { val errorResponse = gson.fromJson(jsonStr, ErrorResponse::class.java) @@ -25,9 +25,11 @@ class HandleErrorInterceptor( ERROR_INVALID_GRANT -> { throw EdxError.InvalidGrantException() } + ERROR_USER_NOT_ACTIVE -> { throw EdxError.UserNotActiveException() } + else -> { return response } diff --git a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt index c91b27184..bdc7c6284 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt @@ -36,4 +36,4 @@ class HeadersInterceptor( }.build() ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt index 3cc6b82ae..38305c007 100644 --- a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt +++ b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt @@ -9,8 +9,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.logging.HttpLoggingInterceptor import org.json.JSONException import org.json.JSONObject -import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.system.notifier.LogoutEvent +import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.auth.data.api.AuthApi import org.openedx.auth.domain.model.AuthResponse import org.openedx.core.ApiConstants @@ -18,6 +17,7 @@ import org.openedx.core.ApiConstants.TOKEN_TYPE_JWT import org.openedx.core.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.utils.TimeUtils import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory @@ -119,7 +119,7 @@ class OauthRefreshTokenAuthenticator( } runBlocking { - appNotifier.send(LogoutEvent()) + appNotifier.send(LogoutEvent(true)) } } @@ -128,7 +128,7 @@ class OauthRefreshTokenAuthenticator( JWT_USER_EMAIL_MISMATCH, -> { runBlocking { - appNotifier.send(LogoutEvent()) + appNotifier.send(LogoutEvent(true)) } } } diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 603876d54..473340beb 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -54,6 +54,7 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences remove(ACCESS_TOKEN) remove(REFRESH_TOKEN) remove(USER) + remove(ACCOUNT) remove(EXPIRES_IN) }.apply() } @@ -70,6 +71,12 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences } get() = getString(REFRESH_TOKEN) + override var pushToken: String + set(value) { + saveString(PUSH_TOKEN, value) + } + get() = getString(PUSH_TOKEN) + override var accessTokenExpiresAt: Long set(value) { saveLong(EXPIRES_IN, value) @@ -152,6 +159,12 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences } get() = getBoolean(APP_WAS_POSITIVE_RATED) + override var canResetAppDirectory: Boolean + set(value) { + saveBoolean(RESET_APP_DIRECTORY, value) + } + get() = getBoolean(RESET_APP_DIRECTORY, true) + override fun setCalendarSyncEventsDialogShown(courseName: String) { saveBoolean(courseName.replaceSpace("_"), true) } @@ -162,6 +175,7 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences companion object { private const val ACCESS_TOKEN = "access_token" private const val REFRESH_TOKEN = "refresh_token" + private const val PUSH_TOKEN = "push_token" private const val EXPIRES_IN = "expires_in" private const val USER = "user" private const val ACCOUNT = "account" @@ -172,5 +186,6 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences private const val VIDEO_SETTINGS_STREAMING_QUALITY = "video_settings_streaming_quality" private const val VIDEO_SETTINGS_DOWNLOAD_QUALITY = "video_settings_download_quality" private const val APP_CONFIG = "app_config" + private const val RESET_APP_DIRECTORY = "reset_app_directory" } } diff --git a/app/src/main/java/org/openedx/app/deeplink/BranchBrazeDeeplinkHandler.kt b/app/src/main/java/org/openedx/app/deeplink/BranchBrazeDeeplinkHandler.kt new file mode 100644 index 000000000..967c3768b --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/BranchBrazeDeeplinkHandler.kt @@ -0,0 +1,26 @@ +package org.openedx.app.deeplink + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.braze.ui.BrazeDeeplinkHandler +import com.braze.ui.actions.UriAction +import org.openedx.app.AppActivity + +internal class BranchBrazeDeeplinkHandler : BrazeDeeplinkHandler() { + override fun gotoUri(context: Context, uriAction: UriAction) { + val deeplink = uriAction.uri.toString() + + if (deeplink.contains("app.link")) { + val intent = Intent(context, AppActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = Uri.parse(deeplink) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra("branch_force_new_session", true) + } + context.startActivity(intent) + } else { + super.gotoUri(context, uriAction) + } + } +} diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt new file mode 100644 index 000000000..ac494df06 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLink.kt @@ -0,0 +1,59 @@ +package org.openedx.app.deeplink + +class DeepLink(params: Map) { + + private val screenName = params[Keys.SCREEN_NAME.value] + private val notificationType = params[Keys.NOTIFICATION_TYPE.value] + val courseId = params[Keys.COURSE_ID.value] + val pathId = params[Keys.PATH_ID.value] + val componentId = params[Keys.COMPONENT_ID.value] + val topicId = params[Keys.TOPIC_ID.value] + val threadId = params[Keys.THREAD_ID.value] + val commentId = params[Keys.COMMENT_ID.value] + val parentId = params[Keys.PARENT_ID.value] + val type = DeepLinkType.typeOf(screenName ?: notificationType ?: "") + + enum class Keys(val value: String) { + SCREEN_NAME("screen_name"), + NOTIFICATION_TYPE("notification_type"), + COURSE_ID("course_id"), + PATH_ID("path_id"), + COMPONENT_ID("component_id"), + TOPIC_ID("topic_id"), + THREAD_ID("thread_id"), + COMMENT_ID("comment_id"), + PARENT_ID("parent_id"), + } +} + +enum class DeepLinkType(val type: String) { + DISCOVERY("discovery"), + DISCOVERY_COURSE_DETAIL("discovery_course_detail"), + DISCOVERY_PROGRAM_DETAIL("discovery_program_detail"), + COURSE_DASHBOARD("course_dashboard"), + COURSE_VIDEOS("course_videos"), + COURSE_DISCUSSION("course_discussion"), + COURSE_DATES("course_dates"), + COURSE_HANDOUT("course_handout"), + COURSE_ANNOUNCEMENT("course_announcement"), + COURSE_COMPONENT("course_component"), + PROGRAM("program"), + DISCUSSION_TOPIC("discussion_topic"), + DISCUSSION_POST("discussion_post"), + DISCUSSION_COMMENT("discussion_comment"), + PROFILE("profile"), + USER_PROFILE("user_profile"), + ENROLL("enroll"), + UNENROLL("unenroll"), + ADD_BETA_TESTER("add_beta_tester"), + REMOVE_BETA_TESTER("remove_beta_tester"), + FORUM_RESPONSE("forum_response"), + FORUM_COMMENT("forum_comment"), + NONE(""); + + companion object { + fun typeOf(type: String): DeepLinkType { + return entries.firstOrNull { it.type == type } ?: NONE + } + } +} diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt new file mode 100644 index 000000000..31564edf7 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -0,0 +1,609 @@ +package org.openedx.app.deeplink + +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.openedx.app.AppRouter +import org.openedx.app.MainFragment +import org.openedx.app.R +import org.openedx.auth.presentation.signin.SignInFragment +import org.openedx.core.FragmentViewType +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.discovery.domain.interactor.DiscoveryInteractor +import org.openedx.discovery.domain.model.Course +import org.openedx.discovery.presentation.catalog.WebViewLink +import org.openedx.discussion.domain.interactor.DiscussionInteractor +import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import kotlin.coroutines.CoroutineContext + +class DeepLinkRouter( + private val config: Config, + private val appRouter: AppRouter, + private val corePreferences: CorePreferences, + private val discoveryInteractor: DiscoveryInteractor, + private val courseInteractor: CourseInteractor, + private val discussionInteractor: DiscussionInteractor +) : CoroutineScope { + + override val coroutineContext: CoroutineContext + get() = Dispatchers.Default + + private val isUserLoggedIn + get() = corePreferences.user != null + + fun makeRoute(fm: FragmentManager, deepLink: DeepLink) { + when (deepLink.type) { + // Discovery + DeepLinkType.DISCOVERY -> { + navigateToDiscoveryScreen(fm = fm) + return + } + + DeepLinkType.DISCOVERY_COURSE_DETAIL -> { + navigateToCourseDetail( + fm = fm, + deepLink = deepLink + ) + return + } + + DeepLinkType.DISCOVERY_PROGRAM_DETAIL -> { + navigateToProgramDetail( + fm = fm, + deepLink = deepLink + ) + return + } + + else -> { + //ignore + } + } + + if (!isUserLoggedIn) { + navigateToSignIn(fm = fm) + return + } + + when (deepLink.type) { + // Program + DeepLinkType.PROGRAM -> { + navigateToProgram( + fm = fm, + deepLink = deepLink + ) + return + } + // Profile + DeepLinkType.PROFILE, + DeepLinkType.USER_PROFILE -> { + navigateToProfile(fm = fm) + return + } + else -> { + //ignore + } + } + + launch(Dispatchers.Main) { + val courseId = deepLink.courseId ?: return@launch navigateToDashboard(fm = fm) + val course = getCourseDetails(courseId) ?: return@launch navigateToDashboard(fm = fm) + if (!course.isEnrolled) { + navigateToDashboard(fm = fm) + return@launch + } + + when (deepLink.type) { + // Course + DeepLinkType.COURSE_DASHBOARD, DeepLinkType.ENROLL, DeepLinkType.ADD_BETA_TESTER -> { + navigateToDashboard(fm = fm) + navigateToCourseDashboard( + fm = fm, + deepLink = deepLink, + courseTitle = course.name + ) + } + + DeepLinkType.UNENROLL, DeepLinkType.REMOVE_BETA_TESTER -> { + navigateToDashboard(fm = fm) + } + + DeepLinkType.COURSE_VIDEOS -> { + navigateToDashboard(fm = fm) + navigateToCourseVideos( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.COURSE_DATES -> { + navigateToDashboard(fm = fm) + navigateToCourseDates( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.COURSE_DISCUSSION -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.COURSE_HANDOUT -> { + navigateToDashboard(fm = fm) + navigateToCourseMore( + fm = fm, + deepLink = deepLink + ) + navigateToCourseHandout( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.COURSE_ANNOUNCEMENT -> { + navigateToDashboard(fm = fm) + navigateToCourseMore( + fm = fm, + deepLink = deepLink + ) + navigateToCourseAnnouncement( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.COURSE_COMPONENT -> { + navigateToDashboard(fm = fm) + navigateToCourseDashboard( + fm = fm, + deepLink = deepLink, + courseTitle = course.name + ) + navigateToCourseComponent( + fm = fm, + deepLink = deepLink + ) + } + + // Discussions + DeepLinkType.DISCUSSION_TOPIC -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionTopic( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.DISCUSSION_POST -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionPost( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.DISCUSSION_COMMENT, DeepLinkType.FORUM_RESPONSE -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionResponse( + fm = fm, + deepLink = deepLink + ) + } + + DeepLinkType.FORUM_COMMENT -> { + navigateToDashboard(fm = fm) + navigateToCourseDiscussion( + fm = fm, + deepLink = deepLink + ) + navigateToDiscussionComment( + fm = fm, + deepLink = deepLink + ) + } + + else -> { + //ignore + } + } + } + } + + // Returns true if there was a successful redirect to the discovery screen + private fun navigateToDiscoveryScreen(fm: FragmentManager): Boolean { + return if (isUserLoggedIn) { + fm.popBackStack() + fm.beginTransaction() + .replace(R.id.container, MainFragment.newInstance(openTab = "DISCOVER")) + .commitNow() + true + } else if (!config.isPreLoginExperienceEnabled()) { + navigateToSignIn(fm = fm) + false + } else if (config.getDiscoveryConfig().isViewTypeWebView()) { + appRouter.navigateToWebDiscoverCourses( + fm = fm, + querySearch = "" + ) + true + } else { + appRouter.navigateToNativeDiscoverCourses( + fm = fm, + querySearch = "" + ) + true + } + } + + private fun navigateToCourseDetail(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + if (navigateToDiscoveryScreen(fm = fm)) { + appRouter.navigateToCourseInfo( + fm = fm, + courseId = courseId, + infoType = WebViewLink.Authority.COURSE_INFO.name + ) + } + } + } + + private fun navigateToProgramDetail(fm: FragmentManager, deepLink: DeepLink) { + deepLink.pathId?.let { pathId -> + if (navigateToDiscoveryScreen(fm = fm)) { + appRouter.navigateToCourseInfo( + fm = fm, + courseId = pathId, + infoType = WebViewLink.Authority.PROGRAM_INFO.name + ) + } + } + } + + private fun navigateToSignIn(fm: FragmentManager) { + if (appRouter.getVisibleFragment(fm = fm) !is SignInFragment) { + appRouter.navigateToSignIn( + fm = fm, + courseId = null, + infoType = null + ) + } + } + + private fun navigateToCourseDashboard( + fm: FragmentManager, + deepLink: DeepLink, + courseTitle: String + ) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = courseTitle, + enrollmentMode = "" + ) + } + } + + private fun navigateToCourseVideos(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "", + openTab = "VIDEOS" + ) + } + } + + private fun navigateToCourseDates(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "", + openTab = "DATES" + ) + } + } + + private fun navigateToCourseDiscussion(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "", + openTab = "DISCUSSIONS" + ) + } + } + + private fun navigateToCourseMore(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToCourseOutline( + fm = fm, + courseId = courseId, + courseTitle = "", + enrollmentMode = "", + openTab = "MORE" + ) + } + } + + private fun navigateToCourseHandout(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToHandoutsWebView( + fm = fm, + courseId = courseId, + type = HandoutsType.Handouts + ) + } + } + + private fun navigateToCourseAnnouncement(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + appRouter.navigateToHandoutsWebView( + fm = fm, + courseId = courseId, + type = HandoutsType.Announcements + ) + } + } + + private fun navigateToCourseComponent(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.componentId?.let { componentId -> + launch { + try { + val courseStructure = courseInteractor.getCourseStructure(courseId) + courseStructure.blockData + .find { it.descendants.contains(componentId) }?.let { block -> + appRouter.navigateToCourseContainer( + fm = fm, + courseId = courseId, + unitId = block.id, + componentId = componentId, + mode = CourseViewMode.FULL + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + + private fun navigateToProgram(fm: FragmentManager, deepLink: DeepLink) { + val pathId = deepLink.pathId + if (pathId == null) { + navigateToPrograms(fm = fm) + } else { + appRouter.navigateToEnrolledProgramInfo( + fm = fm, + pathId = pathId + ) + } + } + + private fun navigateToDiscussionTopic(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.topicId?.let { topicId -> + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + + private fun navigateToDiscussionPost(fm: FragmentManager, deepLink: DeepLink) { + deepLink.courseId?.let { courseId -> + deepLink.topicId?.let { topicId -> + deepLink.threadId?.let { threadId -> + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + val thread = discussionInteractor.getThread( + threadId, + courseId, + topicId + ) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionComments( + fm = fm, + thread = thread + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + } + + private fun navigateToDiscussionResponse(fm: FragmentManager, deepLink: DeepLink) { + val courseId = deepLink.courseId + val topicId = deepLink.topicId + val threadId = deepLink.threadId + val commentId = deepLink.commentId + if (courseId == null || topicId == null || threadId == null || commentId == null) { + return + } + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + val thread = discussionInteractor.getThread( + threadId, + courseId, + topicId + ) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionComments( + fm = fm, + thread = thread + ) + } + val response = discussionInteractor.getResponse(commentId) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionResponses( + fm = fm, + comment = response, + isClosed = false + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + private fun navigateToDiscussionComment(fm: FragmentManager, deepLink: DeepLink) { + val courseId = deepLink.courseId + val topicId = deepLink.topicId + val threadId = deepLink.threadId + val commentId = deepLink.commentId + val parentId = deepLink.parentId + if (courseId == null || topicId == null || threadId == null || commentId == null || parentId == null) { + return + } + launch { + try { + discussionInteractor.getCourseTopics(courseId) + .find { it.id == topicId }?.let { topic -> + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionThread( + fm = fm, + action = DiscussionTopicsViewModel.TOPIC, + courseId = courseId, + topicId = topicId, + title = topic.name, + viewType = FragmentViewType.FULL_CONTENT + ) + } + } + val thread = discussionInteractor.getThread( + threadId, + courseId, + topicId + ) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionComments( + fm = fm, + thread = thread + ) + } + val comment = discussionInteractor.getResponse(parentId) + launch(Dispatchers.Main) { + appRouter.navigateToDiscussionResponses( + fm = fm, + comment = comment, + isClosed = false + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + private fun navigateToDashboard(fm: FragmentManager) { + appRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "LEARN" + ) + } + + private fun navigateToPrograms(fm: FragmentManager) { + appRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "PROGRAMS" + ) + } + + private fun navigateToProfile(fm: FragmentManager) { + appRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "PROFILE" + ) + } + + private suspend fun getCourseDetails(courseId: String): Course? { + return try { + discoveryInteractor.getCourseDetails(courseId) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} diff --git a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt new file mode 100644 index 000000000..c020cf636 --- /dev/null +++ b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt @@ -0,0 +1,8 @@ +package org.openedx.app.deeplink + +enum class HomeTab { + LEARN, + PROGRAMS, + DISCOVER, + PROFILE +} diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index 16a30c0c6..817f05ffd 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -15,9 +15,9 @@ import org.openedx.app.AppAnalytics import org.openedx.app.AppRouter import org.openedx.app.BuildConfig import org.openedx.app.data.storage.PreferencesManager +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase import org.openedx.app.room.DATABASE_NAME -import org.openedx.app.system.notifier.AppNotifier import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter @@ -28,29 +28,34 @@ import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ImageProcessor import org.openedx.core.config.Config import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager +import org.openedx.core.module.billing.BillingProcessor import org.openedx.core.module.download.FileDownloader import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.IAPAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.system.AppCookieManager +import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.DownloadNotifier +import org.openedx.core.system.notifier.IAPNotifier import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.utils.FileUtil import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.calendarsync.CalendarManager import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryAnalytics @@ -66,6 +71,7 @@ import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.data.storage.WhatsNewPreferences import org.openedx.whatsnew.presentation.WhatsNewAnalytics +import org.openedx.core.R as CoreR val appModule = module { @@ -87,6 +93,10 @@ val appModule = module { single { GsonBuilder() .registerTypeAdapter(CourseEnrollments::class.java, CourseEnrollments.Deserializer()) + .registerTypeAdapter( + CourseStructureModel::class.java, + CourseStructureModel.Deserializer(get()) + ) .create() } @@ -94,10 +104,10 @@ val appModule = module { single { CourseNotifier() } single { DiscussionNotifier() } single { ProfileNotifier() } - single { AppUpgradeNotifier() } single { DownloadNotifier() } single { VideoNotifier() } single { DiscoveryNotifier() } + single { IAPNotifier() } single { AppRouter() } single { get() } @@ -108,6 +118,7 @@ val appModule = module { single { get() } single { get() } single { get() } + single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } single { NetworkConnection(get()) } @@ -157,13 +168,22 @@ val appModule = module { DownloadWorkerController(get(), get(), get()) } - single { AppData(versionName = BuildConfig.VERSION_NAME) } + single { + val resourceManager = get() + AppData( + appName = resourceManager.getString(CoreR.string.app_name), + versionName = BuildConfig.VERSION_NAME, + applicationId = BuildConfig.APPLICATION_ID, + ) + } factory { (activity: AppCompatActivity) -> AppReviewManager(activity, get(), get()) } single { TranscriptManager(get()) } single { WhatsNewManager(get(), get(), get(), get()) } single { get() } + single { BillingProcessor(get(), get(named("IODispatcher"))) } + single { AnalyticsManager(get(), get()) } single { get() } single { get() } @@ -175,10 +195,13 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } factory { AgreementProvider(get(), get()) } factory { FacebookAuthHelper() } factory { GoogleAuthHelper(get()) } factory { MicrosoftAuthHelper() } factory { OAuthHelper(get(), get(), get()) } + + factory { FileUtil(get()) } } diff --git a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt index c281d0465..be6b18916 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -2,7 +2,9 @@ package org.openedx.app.di import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import org.koin.core.qualifier.named import org.koin.dsl.module +import org.openedx.app.data.api.NotificationsApi import org.openedx.app.data.networking.AppUpgradeInterceptor import org.openedx.app.data.networking.HandleErrorInterceptor import org.openedx.app.data.networking.HeadersInterceptor @@ -12,6 +14,7 @@ import org.openedx.core.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.api.CookiesApi import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.api.iap.InAppPurchasesApi import org.openedx.discovery.data.api.DiscoveryApi import org.openedx.discussion.data.api.DiscussionApi import org.openedx.profile.data.api.ProfileApi @@ -47,15 +50,25 @@ val networkingModule = module { .build() } + single(named("IAPApiInstance")) { + val config = this.get() + Retrofit.Builder() + .baseUrl(config.getEcommerceURL()) + .client(get()) + .addConverterFactory(GsonConverterFactory.create(get())) + .build() + } + single { provideApi(get()) } single { provideApi(get()) } single { provideApi(get()) } single { provideApi(get()) } single { provideApi(get()) } single { provideApi(get()) } + single { provideApi(get()) } + single { provideApi(get(named("IAPApiInstance"))) } } - inline fun provideApi(retrofit: Retrofit): T { return retrofit.create(T::class.java) } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 4efd1a19e..630441f33 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -12,8 +12,14 @@ import org.openedx.auth.presentation.restore.RestorePasswordViewModel import org.openedx.auth.presentation.signin.SignInViewModel import org.openedx.auth.presentation.signup.SignUpViewModel import org.openedx.core.Validator +import org.openedx.core.data.repository.iap.IAPRepository +import org.openedx.core.domain.interactor.IAPInteractor +import org.openedx.core.domain.model.iap.PurchaseFlowData import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel -import org.openedx.core.presentation.settings.VideoQualityViewModel +import org.openedx.core.presentation.iap.IAPFlow +import org.openedx.core.presentation.iap.IAPViewModel +import org.openedx.core.presentation.settings.video.VideoQualityViewModel +import org.openedx.core.ui.WindowSize import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel @@ -29,9 +35,11 @@ import org.openedx.course.presentation.unit.video.VideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoViewModel import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.settings.download.DownloadQueueViewModel +import org.openedx.courses.presentation.AllEnrolledCoursesViewModel +import org.openedx.courses.presentation.DashboardGalleryViewModel import org.openedx.dashboard.data.repository.DashboardRepository import org.openedx.dashboard.domain.interactor.DashboardInteractor -import org.openedx.dashboard.presentation.DashboardViewModel +import org.openedx.dashboard.presentation.DashboardListViewModel import org.openedx.discovery.data.repository.DiscoveryRepository import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.NativeDiscoveryViewModel @@ -49,10 +57,12 @@ import org.openedx.discussion.presentation.search.DiscussionSearchThreadViewMode import org.openedx.discussion.presentation.threads.DiscussionAddThreadViewModel import org.openedx.discussion.presentation.threads.DiscussionThreadsViewModel import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import org.openedx.learn.presentation.LearnViewModel import org.openedx.profile.data.repository.ProfileRepository import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.anothersaccount.AnothersProfileViewModel +import org.openedx.profile.presentation.calendar.CalendarViewModel import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.manageaccount.ManageAccountViewModel @@ -63,7 +73,19 @@ import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { - viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get()) } + viewModel { + AppViewModel( + get(), + get(), + get(), + get(), + get(named("IODispatcher")), + get(), + get(), + get(), + get() + ) + } viewModel { MainViewModel(get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } @@ -114,9 +136,39 @@ val screenModule = module { } viewModel { RestorePasswordViewModel(get(), get(), get(), get()) } - factory { DashboardRepository(get(), get(), get()) } + factory { DashboardRepository(get(), get(), get(), get()) } factory { DashboardInteractor(get()) } - viewModel { DashboardViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { + DashboardListViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } + + viewModel { (windowSize: WindowSize) -> + DashboardGalleryViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + windowSize + ) + } + viewModel { AllEnrolledCoursesViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { LearnViewModel(get(), get(), get()) } factory { DiscoveryRepository(get(), get(), get()) } factory { DiscoveryInteractor(get()) } @@ -148,10 +200,26 @@ val screenModule = module { viewModel { (qualityType: String) -> VideoQualityViewModel(qualityType, get(), get(), get()) } viewModel { DeleteProfileViewModel(get(), get(), get(), get(), get()) } viewModel { (username: String) -> AnothersProfileViewModel(get(), get(), username) } - viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { + SettingsViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } viewModel { ManageAccountViewModel(get(), get(), get(), get(), get()) } + viewModel { CalendarViewModel(get()) } - single { CourseRepository(get(), get(), get(), get()) } + single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( @@ -179,10 +247,11 @@ val screenModule = module { get() ) } - viewModel { (courseId: String, courseTitle: String, enrollmentMode: String) -> + viewModel { (courseId: String, courseTitle: String, enrollmentMode: String, resumeBlockId: String) -> CourseContainerViewModel( courseId, courseTitle, + resumeBlockId, enrollmentMode, get(), get(), @@ -194,6 +263,8 @@ val screenModule = module { get(), get(), get(), + get(), + get(), get() ) } @@ -211,6 +282,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String) -> @@ -252,6 +324,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } @@ -279,8 +352,10 @@ val screenModule = module { get(), ) } - viewModel { (enrollmentMode: String) -> + viewModel { (courseId: String, courseTitle: String, enrollmentMode: String) -> CourseDatesViewModel( + courseId, + courseTitle, enrollmentMode, get(), get(), @@ -289,6 +364,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String, handoutsType: String) -> @@ -305,8 +381,10 @@ val screenModule = module { single { DiscussionRepository(get(), get(), get()) } factory { DiscussionInteractor(get()) } - viewModel { + viewModel { (courseId: String, courseTitle: String) -> DiscussionTopicsViewModel( + courseId, + courseTitle, get(), get(), get(), @@ -362,6 +440,22 @@ val screenModule = module { ) } + single { IAPRepository(get()) } + factory { IAPInteractor(get(), get()) } + viewModel { (iapFlow: IAPFlow, purchaseFlowData: PurchaseFlowData) -> + IAPViewModel( + iapFlow = iapFlow, + purchaseFlowData = purchaseFlowData, + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } + viewModel { (descendants: List) -> DownloadQueueViewModel( descendants, @@ -372,7 +466,7 @@ val screenModule = module { get(), ) } - viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } + viewModel { HtmlUnitViewModel(get(), get(), get(), get(), get()) } viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get()) } diff --git a/app/src/main/java/org/openedx/app/system/notifier/AppEvent.kt b/app/src/main/java/org/openedx/app/system/notifier/AppEvent.kt deleted file mode 100644 index 1a6f750f4..000000000 --- a/app/src/main/java/org/openedx/app/system/notifier/AppEvent.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.app.system.notifier - -interface AppEvent \ No newline at end of file diff --git a/app/src/main/java/org/openedx/app/system/notifier/LogoutEvent.kt b/app/src/main/java/org/openedx/app/system/notifier/LogoutEvent.kt deleted file mode 100644 index 209ac8815..000000000 --- a/app/src/main/java/org/openedx/app/system/notifier/LogoutEvent.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.app.system.notifier - -class LogoutEvent : AppEvent diff --git a/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt new file mode 100644 index 000000000..60917940e --- /dev/null +++ b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt @@ -0,0 +1,95 @@ +package org.openedx.app.system.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.os.Build +import android.os.SystemClock +import androidx.core.app.NotificationCompat +import com.braze.push.BrazeFirebaseMessagingService +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import org.koin.android.ext.android.inject +import org.openedx.app.AppActivity +import org.openedx.app.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences + +class OpenEdXFirebaseMessagingService : FirebaseMessagingService() { + + private val preferences: CorePreferences by inject() + private val config: Config by inject() + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + if (BrazeFirebaseMessagingService.handleBrazeRemoteMessage(this, message)) { + // This Remote Message originated from Braze and a push notification was displayed. + // No further action is needed. + return + } else { + // This Remote Message did not originate from Braze. + handlePushNotification(message) + } + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + preferences.pushToken = token + if (preferences.user != null) { + SyncFirebaseTokenWorker.schedule(this) + } + } + + private fun handlePushNotification(message: RemoteMessage) { + val notification = message.notification ?: return + val data = message.data + + val intent = Intent(this, AppActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + data.forEach { (k, v) -> + intent.putExtra(k, v) + } + + val code = createId() + val pendingIntent = PendingIntent.getActivity( + this, + code, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val channelId = "${config.getPlatformName()}_channel" + val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val notificationBuilder = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(notification.title) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(notification.body)) + .setAutoCancel(true) + .setSound(defaultSoundUri) + .setContentIntent(pendingIntent) + + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Since android Oreo notification channel is needed. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + config.getPlatformName(), + NotificationManager.IMPORTANCE_HIGH, + ) + notificationManager.createNotificationChannel(channel) + } + + notificationManager.notify(code, notificationBuilder.build()) + } + + private fun createId(): Int { + return SystemClock.uptimeMillis().toInt() + } +} diff --git a/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt b/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt new file mode 100644 index 000000000..322bdf49b --- /dev/null +++ b/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt @@ -0,0 +1,46 @@ +package org.openedx.app.system.push + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.tasks.await +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.core.data.storage.CorePreferences + +class RefreshFirebaseTokenWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params), + KoinComponent { + + private val preferences: CorePreferences by inject() + + override suspend fun doWork(): Result { + FirebaseMessaging.getInstance().deleteToken().await() + + val newPushToken = FirebaseMessaging.getInstance().getToken().await() + + preferences.pushToken = newPushToken + + return Result.success() + } + + companion object { + private const val WORKER_TAG = "RefreshFirebaseTokenWorker" + + fun schedule(context: Context) { +// val work = OneTimeWorkRequest +// .Builder(RefreshFirebaseTokenWorker::class.java) +// .addTag(WORKER_TAG) +// .build() +// WorkManager.getInstance(context).beginUniqueWork( +// WORKER_TAG, +// ExistingWorkPolicy.REPLACE, +// work +// ).enqueue() + } + } +} diff --git a/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt b/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt new file mode 100644 index 000000000..d90d19f1f --- /dev/null +++ b/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt @@ -0,0 +1,47 @@ +package org.openedx.app.system.push + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.app.data.api.NotificationsApi +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.module.DownloadWorker + +class SyncFirebaseTokenWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params), + KoinComponent { + + private val preferences: CorePreferences by inject() + private val api: NotificationsApi by inject() + + override suspend fun doWork(): Result { + if (preferences.user != null && preferences.pushToken.isNotEmpty()) { + + api.syncFirebaseToken(preferences.pushToken) + + return Result.success() + } + return Result.failure() + } + + companion object { + private const val WORKER_TAG = "SyncFirebaseTokenWorker" + + fun schedule(context: Context) { +// val work = OneTimeWorkRequest +// .Builder(SyncFirebaseTokenWorker::class.java) +// .addTag(WORKER_TAG) +// .build() +// WorkManager.getInstance(context).beginUniqueWork( +// WORKER_TAG, +// ExistingWorkPolicy.REPLACE, +// work +// ).enqueue() + } + } +} diff --git a/app/src/main/res/color/bottom_nav_color.xml b/app/src/main/res/color/bottom_nav_color.xml new file mode 100644 index 000000000..4e2851e90 --- /dev/null +++ b/app/src/main/res/color/bottom_nav_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/app_ic_rows.xml b/app/src/main/res/drawable/app_ic_rows.xml index 41b74e9b4..eabe550d3 100644 --- a/app/src/main/res/drawable/app_ic_rows.xml +++ b/app/src/main/res/drawable/app_ic_rows.xml @@ -1,38 +1,10 @@ - - - - - - - + android:width="20dp" + android:height="17dp" + android:viewportWidth="20" + android:viewportHeight="17"> + diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index eb6f37a6f..9794b7bd7 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -14,11 +14,13 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - - - \ No newline at end of file + diff --git a/app/src/main/res/menu/bottom_view_menu.xml b/app/src/main/res/menu/bottom_view_menu.xml index 60ba4f78c..f97e849f7 100644 --- a/app/src/main/res/menu/bottom_view_menu.xml +++ b/app/src/main/res/menu/bottom_view_menu.xml @@ -2,27 +2,21 @@ + android:icon="@drawable/app_ic_rows" + android:title="@string/app_navigation_learn" /> - - + android:icon="@drawable/app_ic_home" + android:title="@string/app_navigation_discovery" /> + android:icon="@drawable/app_ic_profile" + android:title="@string/app_navigation_profile" /> - \ No newline at end of file + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8e4178d90..17d58ded3 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -5,7 +5,7 @@ Назад Всі курси - Мої курси + Мої курси Програми Профіль - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f24815f30..baa1c2a89 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,7 +4,7 @@ Previous Discover - Dashboard + Learn Programs Profile - \ No newline at end of file + diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 40b3e813d..87a34e790 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -1,5 +1,6 @@ package org.openedx +import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -21,13 +22,16 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.app.AppAnalytics +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.room.AppDatabase -import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.system.notifier.LogoutEvent +import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.config.Config +import org.openedx.core.config.FirebaseConfig import org.openedx.core.data.model.User +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.utils.FileUtil @ExperimentalCoroutinesApi class AppViewModelTest { @@ -42,6 +46,9 @@ class AppViewModelTest { private val room = mockk() private val preferencesManager = mockk() private val analytics = mockk() + private val fileUtil = mockk() + private val deepLinkRouter = mockk() + private val context = mockk() private val user = User(0, "", "", "") @@ -60,8 +67,20 @@ class AppViewModelTest { every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns user every { notifier.notifier } returns flow { } - val viewModel = - AppViewModel(config, notifier, room, preferencesManager, dispatcher, analytics) + every { preferencesManager.canResetAppDirectory } returns false + every { preferencesManager.pushToken } returns "" + + val viewModel = AppViewModel( + config, + notifier, + room, + preferencesManager, + dispatcher, + analytics, + deepLinkRouter, + fileUtil, + context + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -75,15 +94,28 @@ class AppViewModelTest { @Test fun forceLogout() = runTest { every { notifier.notifier } returns flow { - emit(LogoutEvent()) + emit(LogoutEvent(true)) } every { preferencesManager.clear() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns user every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit - val viewModel = - AppViewModel(config, notifier, room, preferencesManager, dispatcher, analytics) + every { preferencesManager.canResetAppDirectory } returns false + every { preferencesManager.pushToken } returns "" + every { config.getFirebaseConfig() } returns FirebaseConfig() + + val viewModel = AppViewModel( + config, + notifier, + room, + preferencesManager, + dispatcher, + analytics, + deepLinkRouter, + fileUtil, + context + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -98,16 +130,29 @@ class AppViewModelTest { @Test fun forceLogoutTwice() = runTest { every { notifier.notifier } returns flow { - emit(LogoutEvent()) - emit(LogoutEvent()) + emit(LogoutEvent(true)) + emit(LogoutEvent(true)) } every { preferencesManager.clear() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns user every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit - val viewModel = - AppViewModel(config, notifier, room, preferencesManager, dispatcher, analytics) + every { preferencesManager.canResetAppDirectory } returns false + every { preferencesManager.pushToken } returns "" + every { config.getFirebaseConfig() } returns FirebaseConfig() + + val viewModel = AppViewModel( + config, + notifier, + room, + preferencesManager, + dispatcher, + analytics, + deepLinkRouter, + fileUtil, + context + ) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) diff --git a/auth/src/main/java/org/openedx/auth/data/model/RegistrationFields.kt b/auth/src/main/java/org/openedx/auth/data/model/RegistrationFields.kt index ef300156f..378bdbfbe 100644 --- a/auth/src/main/java/org/openedx/auth/data/model/RegistrationFields.kt +++ b/auth/src/main/java/org/openedx/auth/data/model/RegistrationFields.kt @@ -24,6 +24,8 @@ data class RegistrationFields( val exposed: Boolean?, @SerializedName("required") val required: Boolean?, + @SerializedName("defaultValue") + val defaultValue: Boolean?, @SerializedName("restrictions") val restrictions: Restrictions?, @SerializedName("options") @@ -38,6 +40,7 @@ data class RegistrationFields( instructions = instructions ?: "", exposed = exposed ?: false, required = required ?: false, + defaultValue = defaultValue ?: true, restrictions = restrictions?.mapToDomain() ?: RegistrationField.Restrictions(), options = options?.map { it.mapToDomain() } ?: emptyList() ) @@ -75,4 +78,4 @@ data class RegistrationFields( } } -} \ No newline at end of file +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt index e87ad9674..40125a18e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt @@ -3,9 +3,14 @@ package org.openedx.auth.presentation interface AuthAnalytics { fun setUserIdForSession(userId: Long) fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) } enum class AuthAnalyticsEvent(val eventName: String, val biValue: String) { + Logistration( + "Logistration", + "edx.bi.app.logistration" + ), DISCOVERY_COURSES_SEARCH( "Logistration:Courses Search", "edx.bi.app.logistration.courses_search" @@ -14,6 +19,14 @@ enum class AuthAnalyticsEvent(val eventName: String, val biValue: String) { "Logistration:Explore All Courses", "edx.bi.app.logistration.explore.all.courses" ), + SIGN_IN( + "Logistration:Sign In", + "edx.bi.app.logistration.signin" + ), + REGISTER( + "Logistration:Register", + "edx.bi.app.logistration.register" + ), REGISTER_CLICKED( "Logistration:Register Clicked", "edx.bi.app.logistration.register.clicked" diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt index 9b1266119..945acf02e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -4,7 +4,12 @@ import androidx.fragment.app.FragmentManager interface AuthRouter { - fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) + fun navigateToMain( + fm: FragmentManager, + courseId: String?, + infoType: String?, + openTab: String = "" + ) fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt index 738364c34..10539746b 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -131,11 +132,11 @@ private fun LogistrationScreen( LogistrationLogoView() Text( text = stringResource(id = R.string.pre_auth_title), - color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.headlineSmall, modifier = Modifier .testTag("txt_screen_title") - .padding(bottom = 40.dp) + .padding(bottom = 40.dp), + color = MaterialTheme.appColors.textPrimary, ) val focusManager = LocalFocusManager.current Column(Modifier.padding(bottom = 8.dp)) { @@ -145,6 +146,7 @@ private fun LogistrationScreen( .padding(bottom = 10.dp), style = MaterialTheme.appTypography.titleMedium, text = stringResource(id = R.string.pre_auth_search_title), + color = MaterialTheme.appColors.textPrimary, ) SearchBar( modifier = Modifier @@ -176,8 +178,9 @@ private fun LogistrationScreen( onSearchClick("") }, text = stringResource(id = R.string.pre_auth_explore_all_courses), - color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge + color = MaterialTheme.appColors.textHyperLink, + style = MaterialTheme.appTypography.labelLarge, + textDecoration = TextDecoration.Underline ) Spacer(modifier = Modifier.weight(1f)) diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt index e48a5e8be..3306ccfa3 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt @@ -18,6 +18,10 @@ class LogistrationViewModel( private val discoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() + init { + logLogistrationScreenEvent() + } + fun navigateToSignIn(parentFragmentManager: FragmentManager) { router.navigateToSignIn(parentFragmentManager, courseId, null) logEvent(AuthAnalyticsEvent.SIGN_IN_CLICKED) @@ -62,4 +66,14 @@ class LogistrationViewModel( } ) } + + private fun logLogistrationScreenEvent() { + val event = AuthAnalyticsEvent.Logistration + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + } + ) + } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt index 18cf169bc..84d2d584e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -127,9 +128,9 @@ private fun RestorePasswordScreen( ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberScrollState() - var email by rememberSaveable { - mutableStateOf("") - } + var email by rememberSaveable { mutableStateOf("") } + var isEmailError by rememberSaveable { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current Scaffold( scaffoldState = scaffoldState, @@ -269,12 +270,20 @@ private fun RestorePasswordScreen( description = stringResource(id = authR.string.auth_example_email), onValueChanged = { email = it + isEmailError = false }, imeAction = ImeAction.Done, keyboardActions = { - it.clearFocus() - onRestoreButtonClick(email) - } + keyboardController?.hide() + if (email.isNotEmpty()) { + it.clearFocus() + onRestoreButtonClick(email) + } else { + isEmailError = email.isEmpty() + } + }, + isError = isEmailError, + errorMessages = stringResource(id = authR.string.auth_error_empty_email) ) Spacer(Modifier.height(50.dp)) if (uiState == RestorePasswordUIState.Loading) { @@ -292,7 +301,12 @@ private fun RestorePasswordScreen( modifier = buttonWidth.testTag("btn_reset_password"), text = stringResource(id = authR.string.auth_reset_password), onClick = { - onRestoreButtonClick(email) + keyboardController?.hide() + if (email.isNotEmpty()) { + onRestoreButtonClick(email) + } else { + isEmailError = email.isEmpty() + } } ) } diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt index b21c694da..6827d8e78 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt @@ -16,14 +16,14 @@ import org.openedx.core.extension.isEmailValid import org.openedx.core.extension.isInternetError import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent class RestorePasswordViewModel( private val interactor: AuthInteractor, private val resourceManager: ResourceManager, private val analytics: AuthAnalytics, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier ) : BaseViewModel() { private val _uiState = MutableLiveData() @@ -81,8 +81,10 @@ class RestorePasswordViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } } } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index 7ebc5a569..dd03bdaae 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -32,8 +32,9 @@ import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Logger import org.openedx.core.R as CoreRes @@ -42,7 +43,7 @@ class SignInViewModel( private val resourceManager: ResourceManager, private val preferencesManager: CorePreferences, private val validator: Validator, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier, private val analytics: AuthAnalytics, private val oAuthHelper: OAuthHelper, private val router: AuthRouter, @@ -77,6 +78,7 @@ class SignInViewModel( init { collectAppUpgradeEvent() + logSignInScreenEvent() } fun login(username: String, password: String) { @@ -107,6 +109,7 @@ class SignInViewModel( ) } ) + appNotifier.send(SignInEvent()) } catch (e: Exception) { if (e is EdxError.InvalidGrantException) { _uiMessage.value = @@ -125,8 +128,10 @@ class SignInViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } } } } @@ -170,6 +175,7 @@ class SignInViewModel( _uiState.update { it.copy(loginSuccess = true) } setUserId() _uiState.update { it.copy(showProgress = false) } + appNotifier.send(SignInEvent()) } } @@ -240,4 +246,14 @@ class SignInViewModel( } ) } + + private fun logSignInScreenEvent() { + val event = AuthAnalyticsEvent.SIGN_IN + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + } + ) + } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 77e290994..4b91aaa4f 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -49,6 +50,8 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -57,6 +60,7 @@ import org.openedx.auth.R import org.openedx.auth.presentation.signin.AuthEvent import org.openedx.auth.presentation.signin.SignInUIState import org.openedx.auth.presentation.ui.LoginTextField +import org.openedx.auth.presentation.ui.PasswordVisibilityIcon import org.openedx.auth.presentation.ui.SocialAuthView import org.openedx.core.UIMessage import org.openedx.core.extension.TextConverter @@ -195,7 +199,8 @@ internal fun LoginScreen( modifier = Modifier.testTag("txt_${state.agreement.name}"), fullText = linkedText.text, hyperLinks = linkedText.links, - linkTextColor = MaterialTheme.appColors.primary, + linkTextColor = MaterialTheme.appColors.textHyperLink, + linkTextDecoration = TextDecoration.Underline, action = { link -> onEvent(AuthEvent.OpenLink(linkedText.links, link)) }, @@ -216,6 +221,9 @@ private fun AuthForm( ) { var login by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } + val keyboardController = LocalSoftwareKeyboardController.current + var isEmailError by rememberSaveable { mutableStateOf(false) } + var isPasswordError by rememberSaveable { mutableStateOf(false) } Column(horizontalAlignment = Alignment.CenterHorizontally) { LoginTextField( @@ -225,7 +233,11 @@ private fun AuthForm( description = stringResource(id = R.string.auth_enter_email_username), onValueChanged = { login = it - }) + isEmailError = false + }, + isError = isEmailError, + errorMessages = stringResource(id = R.string.auth_error_empty_username_email) + ) Spacer(modifier = Modifier.height(18.dp)) PasswordTextField( @@ -233,10 +245,18 @@ private fun AuthForm( .fillMaxWidth(), onValueChanged = { password = it + isPasswordError = false }, onPressDone = { - onEvent(AuthEvent.SignIn(login = login, password = password)) - } + keyboardController?.hide() + if (password.isNotEmpty()) { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } else { + isEmailError = login.isEmpty() + isPasswordError = password.isEmpty() + } + }, + isError = isPasswordError, ) Row( @@ -252,7 +272,7 @@ private fun AuthForm( onEvent(AuthEvent.RegisterClick) }, text = stringResource(id = coreR.string.core_register), - color = MaterialTheme.appColors.primary, + color = MaterialTheme.appColors.textHyperLink, style = MaterialTheme.appTypography.labelLarge ) } @@ -264,7 +284,7 @@ private fun AuthForm( onEvent(AuthEvent.ForgotPasswordClick) }, text = stringResource(id = R.string.auth_forgot_password), - color = MaterialTheme.appColors.primary, + color = MaterialTheme.appColors.textHyperLink, style = MaterialTheme.appTypography.labelLarge ) } @@ -275,8 +295,16 @@ private fun AuthForm( OpenEdXButton( modifier = buttonWidth.testTag("btn_sign_in"), text = stringResource(id = coreR.string.core_sign_in), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.primaryButtonBackground, onClick = { - onEvent(AuthEvent.SignIn(login = login, password = password)) + keyboardController?.hide() + if (login.isNotEmpty() && password.isNotEmpty()) { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } else { + isEmailError = login.isEmpty() + isPasswordError = password.isEmpty() + } } ) } @@ -288,6 +316,7 @@ private fun AuthForm( isMicrosoftAuthEnabled = state.isMicrosoftAuthEnabled, isSignIn = true, ) { + keyboardController?.hide() onEvent(AuthEvent.SocialSignIn(it)) } } @@ -297,15 +326,16 @@ private fun AuthForm( @Composable private fun PasswordTextField( modifier: Modifier = Modifier, + isError: Boolean, onValueChanged: (String) -> Unit, onPressDone: () -> Unit, ) { var passwordTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf( - TextFieldValue("") - ) + mutableStateOf(TextFieldValue("")) } + var isPasswordVisible by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current + Text( modifier = Modifier .testTag("txt_password_label") @@ -314,7 +344,9 @@ private fun PasswordTextField( color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.labelLarge ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( modifier = modifier.testTag("tf_password"), value = passwordTextFieldValue, @@ -323,8 +355,10 @@ private fun PasswordTextField( onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { @@ -335,18 +369,37 @@ private fun PasswordTextField( style = MaterialTheme.appTypography.bodyMedium ) }, + trailingIcon = { + PasswordVisibilityIcon( + isPasswordVisible = isPasswordVisible, + onClick = { isPasswordVisible = !isPasswordVisible } + ) + }, keyboardOptions = KeyboardOptions.Default.copy( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done ), - visualTransformation = PasswordVisualTransformation(), + visualTransformation = if (isPasswordVisible) VisualTransformation.None + else PasswordVisualTransformation(), keyboardActions = KeyboardActions { focusManager.clearFocus() onPressDone() }, + isError = isError, textStyle = MaterialTheme.appTypography.bodyMedium, - singleLine = true + singleLine = true, ) + if (isError) { + Text( + modifier = Modifier + .testTag("txt_password_error") + .fillMaxWidth() + .padding(top = 4.dp), + text = stringResource(id = R.string.auth_error_empty_password), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.error, + ) + } } @Preview(uiMode = UI_MODE_NIGHT_NO) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt index 0f7873b78..7e60beb1d 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt @@ -2,7 +2,7 @@ package org.openedx.auth.presentation.signup import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.app.AppUpgradeEvent data class SignUpUIState( val allFields: List = emptyList(), diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt index 8fafe40ff..0826fca5c 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt @@ -31,7 +31,9 @@ import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.domain.model.createHonorCodeField import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Logger import org.openedx.core.R as coreR @@ -40,7 +42,7 @@ class SignUpViewModel( private val resourceManager: ResourceManager, private val analytics: AuthAnalytics, private val preferencesManager: CorePreferences, - private val appUpgradeNotifier: AppUpgradeNotifier, + private val appNotifier: AppNotifier, private val agreementProvider: AgreementProvider, private val oAuthHelper: OAuthHelper, private val config: Config, @@ -71,6 +73,7 @@ class SignUpViewModel( init { collectAppUpgradeEvent() + logRegisterScreenEvent() } fun getRegistrationFields() { @@ -175,6 +178,7 @@ class SignUpViewModel( ) setUserId() _uiState.update { it.copy(successLogin = true, isButtonLoading = false) } + appNotifier.send(SignInEvent()) } else { exchangeToken(socialAuth) } @@ -226,9 +230,14 @@ class SignUpViewModel( interactor.loginSocial(socialAuth.accessToken, socialAuth.authType) }.onFailure { val fields = uiState.value.allFields.toMutableList() - .filter { field -> field.type != RegistrationFieldType.PASSWORD } - updateField(ApiConstants.NAME, socialAuth.name) - updateField(ApiConstants.EMAIL, socialAuth.email) + .filter { it.type != RegistrationFieldType.PASSWORD } + .map { field -> + when (field.name) { + ApiConstants.NAME -> field.copy(placeholder = socialAuth.name) + ApiConstants.EMAIL -> field.copy(placeholder = socialAuth.email) + else -> field + } + } setErrorInstructions(emptyMap()) _uiState.update { it.copy( @@ -250,6 +259,7 @@ class SignUpViewModel( ) _uiState.update { it.copy(successLogin = true) } logger.d { "Social login (${socialAuth.authType.methodName}) success" } + appNotifier.send(SignInEvent()) } } @@ -269,8 +279,10 @@ class SignUpViewModel( private fun collectAppUpgradeEvent() { viewModelScope.launch { - appUpgradeNotifier.notifier.collect { event -> - _uiState.update { it.copy(appUpgradeEvent = event) } + appNotifier.notifier.collect { event -> + if (event is AppUpgradeEvent) { + _uiState.update { it.copy(appUpgradeEvent = event) } + } } } } @@ -313,4 +325,14 @@ class SignUpViewModel( } ) } + + private fun logRegisterScreenEvent() { + val event = AuthAnalyticsEvent.REGISTER + analytics.logScreenEvent( + screenName = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + } + ) + } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 2e2180d83..f820b5cf8 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices @@ -317,10 +318,11 @@ internal fun SignUpView( Text( modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp), + .padding(top = 8.dp), text = stringResource( id = R.string.auth_compete_registration ), + fontWeight = FontWeight.Bold, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleSmall ) @@ -329,7 +331,7 @@ internal fun SignUpView( modifier = Modifier .testTag("txt_sign_up_title") .fillMaxWidth(), - text = stringResource(id = R.string.auth_sign_up), + text = stringResource(id = coreR.string.core_register), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.displaySmall ) @@ -437,7 +439,10 @@ internal fun SignUpView( OpenEdXButton( modifier = buttonWidth.testTag("btn_create_account"), text = stringResource(id = R.string.auth_create_account), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.primaryButtonBackground, onClick = { + keyboardController?.hide() showErrorMap.clear() onRegisterClick(AuthType.PASSWORD) } @@ -451,6 +456,7 @@ internal fun SignUpView( isMicrosoftAuthEnabled = uiState.isMicrosoftAuthEnabled, isSignIn = false, ) { + keyboardController?.hide() onRegisterClick(it) } } @@ -474,7 +480,10 @@ private fun RegistrationScreenPreview() { SignUpView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = SignUpUIState( - allFields = listOf(field, field, field.copy(required = false)), + allFields = listOf(field), + requiredFields = listOf(field, field), + optionalFields = listOf(field, field), + agreementFields = listOf(field), ), uiMessage = null, onBackClick = {}, @@ -517,6 +526,7 @@ private val field = RegistrationField( instructions = "Enter your fullname", exposed = false, required = true, + defaultValue = true, restrictions = RegistrationField.Restrictions(), options = listOf(option, option), errorInstructions = "" diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt index 25a9434d1..b2dee1919 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt @@ -3,11 +3,15 @@ package org.openedx.auth.presentation.signup.compose import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Devices @@ -26,21 +30,36 @@ internal fun SocialSignedView(authType: AuthType) { Column( modifier = Modifier .background( - color = MaterialTheme.appColors.secondary, + color = MaterialTheme.appColors.authSSOSuccessBackground, shape = MaterialTheme.appShapes.buttonShape ) .padding(20.dp) ) { - Text( - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - text = stringResource( - id = R.string.auth_social_signed_title, - authType.methodName + Row { + Icon( + modifier = Modifier + .padding(end = 8.dp) + .size(20.dp), + painter = painterResource(id = coreR.drawable.ic_core_check), + tint = MaterialTheme.appColors.successBackground, + contentDescription = "" ) - ) + + Text( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + text = stringResource( + id = R.string.auth_social_signed_title, + authType.methodName + ) + ) + } + Text( - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier.padding(top = 8.dp, start = 28.dp), + fontSize = 14.sp, + fontWeight = FontWeight.Normal, text = stringResource( id = R.string.auth_social_signed_desc, stringResource(id = coreR.string.app_name) diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt index 4f98ea50c..087c3aa11 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt @@ -12,10 +12,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text @@ -23,6 +25,8 @@ import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -44,6 +48,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.openedx.auth.R @@ -65,11 +70,15 @@ fun RequiredFields( showErrorMap: MutableMap, selectableNamesMap: MutableMap, onFieldUpdated: (String, String) -> Unit, - onSelectClick: (String, RegistrationField, List) -> Unit + onSelectClick: (String, RegistrationField, List) -> Unit, ) { fields.forEach { field -> when (field.type) { - RegistrationFieldType.TEXT, RegistrationFieldType.EMAIL, RegistrationFieldType.CONFIRM_EMAIL, RegistrationFieldType.PASSWORD -> { + RegistrationFieldType.TEXT, + RegistrationFieldType.EMAIL, + RegistrationFieldType.CONFIRM_EMAIL, + RegistrationFieldType.PASSWORD, + -> { InputRegistrationField( modifier = Modifier.fillMaxWidth(), isErrorShown = showErrorMap[field.name] ?: true, @@ -95,7 +104,9 @@ fun RequiredFields( } RegistrationFieldType.CHECKBOX -> { - CheckboxField(text = field.label, defaultValue = field.defaultValue) { + CheckboxField( + field = field + ) { onFieldUpdated(field.name, it.toString()) } } @@ -170,7 +181,8 @@ fun OptionalFields( HyperlinkText( fullText = linkedText.text, hyperLinks = linkedText.links, - linkTextColor = MaterialTheme.appColors.primary, + linkTextColor = MaterialTheme.appColors.textHyperLink, + linkTextDecoration = TextDecoration.Underline, action = { hyperLinkAction?.invoke(linkedText.links, it) }, @@ -178,7 +190,9 @@ fun OptionalFields( } RegistrationFieldType.CHECKBOX -> { - CheckboxField(text = field.label, defaultValue = field.defaultValue) { + CheckboxField( + field = field + ) { onFieldUpdated(field.name, it.toString()) } } @@ -224,9 +238,11 @@ fun LoginTextField( modifier: Modifier = Modifier, title: String, description: String, + isError: Boolean = false, + errorMessages: String = "", onValueChanged: (String) -> Unit, imeAction: ImeAction = ImeAction.Next, - keyboardActions: (FocusManager) -> Unit = { it.moveFocus(FocusDirection.Down) } + keyboardActions: (FocusManager) -> Unit = { it.moveFocus(FocusDirection.Down) }, ) { var loginTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf( @@ -250,8 +266,10 @@ fun LoginTextField( onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { @@ -271,8 +289,20 @@ fun LoginTextField( }, textStyle = MaterialTheme.appTypography.bodyMedium, singleLine = true, - modifier = modifier.testTag("tf_email") + modifier = modifier.testTag("tf_email"), + isError = isError ) + if (isError) { + Text( + modifier = Modifier + .testTag("txt_email_error") + .fillMaxWidth() + .padding(top = 4.dp), + text = errorMessages, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.error, + ) + } } @Composable @@ -280,16 +310,20 @@ fun InputRegistrationField( modifier: Modifier, isErrorShown: Boolean, registrationField: RegistrationField, - onValueChanged: (String, String, Boolean) -> Unit + onValueChanged: (String, String, Boolean) -> Unit, ) { var inputRegistrationFieldValue by rememberSaveable { mutableStateOf(registrationField.placeholder) } + var isPasswordVisible by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current - val visualTransformation = if (registrationField.type == RegistrationFieldType.PASSWORD) { - PasswordVisualTransformation() - } else { - VisualTransformation.None + val visualTransformation = remember(isPasswordVisible) { + if (registrationField.type == RegistrationFieldType.PASSWORD && !isPasswordVisible) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + } } val keyboardType = when (registrationField.type) { RegistrationFieldType.CONFIRM_EMAIL, RegistrationFieldType.EMAIL -> KeyboardType.Email @@ -311,6 +345,18 @@ fun InputRegistrationField( } else { registrationField.instructions } + val trailingIcon: @Composable (() -> Unit)? = + if (registrationField.type == RegistrationFieldType.PASSWORD) { + { + PasswordVisibilityIcon( + isPasswordVisible = isPasswordVisible, + onClick = { isPasswordVisible = !isPasswordVisible } + ) + } + } else { + null + } + Column { Text( modifier = Modifier @@ -332,8 +378,11 @@ fun InputRegistrationField( } }, colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = MaterialTheme.appColors.textFieldText, + backgroundColor = MaterialTheme.appColors.textFieldBackground, + focusedBorderColor = MaterialTheme.appColors.textFieldBorder, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground + cursorColor = MaterialTheme.appColors.textFieldText, ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { @@ -352,6 +401,7 @@ fun InputRegistrationField( keyboardActions = KeyboardActions { focusManager.moveFocus(FocusDirection.Down) }, + trailingIcon = trailingIcon, textStyle = MaterialTheme.appTypography.bodyMedium, singleLine = isSingleLine, modifier = modifier.testTag("tf_${registrationField.name.tagId()}") @@ -371,7 +421,7 @@ fun SelectableRegisterField( registrationField: RegistrationField, isErrorShown: Boolean, initialValue: String, - onClick: (String, List) -> Unit + onClick: (String, List) -> Unit, ) { val helperTextColor = if (registrationField.errorInstructions.isEmpty()) { MaterialTheme.appColors.textSecondary @@ -411,6 +461,7 @@ fun SelectableRegisterField( OutlinedTextField( readOnly = true, enabled = false, + singleLine = true, value = initialValue, colors = TextFieldDefaults.outlinedTextFieldColors( unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, @@ -458,7 +509,7 @@ fun SelectableRegisterField( fun ExpandableText( modifier: Modifier = Modifier, isExpanded: Boolean, - onClick: (Boolean) -> Unit + onClick: (Boolean) -> Unit, ) { val transitionState = remember { MutableTransitionState(isExpanded).apply { @@ -503,6 +554,26 @@ fun ExpandableText( } } +@Composable +internal fun PasswordVisibilityIcon( + isPasswordVisible: Boolean, + onClick: () -> Unit, +) { + val (image, description) = if (isPasswordVisible) { + Icons.Filled.VisibilityOff to stringResource(R.string.auth_accessibility_hide_password) + } else { + Icons.Filled.Visibility to stringResource(R.string.auth_accessibility_show_password) + } + + IconButton(onClick = onClick) { + Icon( + imageVector = image, + contentDescription = description, + tint = MaterialTheme.appColors.onSurface + ) + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -601,6 +672,7 @@ private val field = RegistrationField( instructions = "Enter your fullname", exposed = false, required = true, + defaultValue = true, restrictions = RegistrationField.Restrictions(), options = listOf(option, option), errorInstructions = "" diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt index b134cb59a..54ba41f14 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt @@ -14,6 +14,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import org.openedx.core.domain.model.RegistrationField +import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -21,11 +23,17 @@ import org.openedx.core.ui.theme.appTypography @Composable internal fun CheckboxField( - text: String, - defaultValue: Boolean, + field: RegistrationField, onValueChanged: (Boolean) -> Unit ) { - var checkedState by remember { mutableStateOf(defaultValue) } + // Initialize checkedState with user-inputted placeholder value, + // fallback to defaultValue if not provided + var checkedState by remember { + mutableStateOf( + field.placeholder.takeIf { it.isNotEmpty() }?.toBoolean() ?: field.defaultValue + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( checked = checkedState, @@ -43,7 +51,7 @@ internal fun CheckboxField( checkedState = !checkedState onValueChanged(checkedState) }, - text = text, + text = field.label, style = MaterialTheme.appTypography.bodySmall, ) } @@ -55,8 +63,19 @@ internal fun CheckboxField( private fun CheckboxFieldPreview() { OpenEdXTheme { CheckboxField( - text = "Test", - defaultValue = true, + field = RegistrationField( + "", + "Checkbox label", + RegistrationFieldType.CHECKBOX, + "", + instructions = "", + exposed = true, + required = false, + defaultValue = true, + restrictions = RegistrationField.Restrictions(), + options = listOf(), + errorInstructions = "" + ) ) {} } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt index 336c09f8f..c8ab3df16 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt @@ -40,13 +40,12 @@ internal fun SocialAuthView( } else { R.string.auth_continue_google } - OpenEdXOutlinedButton( + OpenEdXButton( modifier = Modifier .testTag("btn_google_auth") .padding(top = 24.dp) .fillMaxWidth(), - backgroundColor = MaterialTheme.appColors.background, - borderColor = MaterialTheme.appColors.primary, + backgroundColor = MaterialTheme.appColors.authGoogleButtonBackground, textColor = Color.Unspecified, onClick = { onEvent(AuthType.GOOGLE) @@ -62,7 +61,8 @@ internal fun SocialAuthView( modifier = Modifier .testTag("txt_google_auth") .padding(start = 10.dp), - text = stringResource(id = stringRes) + text = stringResource(id = stringRes), + color = MaterialTheme.appColors.textPrimaryLight, ) } } @@ -87,13 +87,13 @@ internal fun SocialAuthView( Icon( painter = painterResource(id = R.drawable.ic_auth_facebook), contentDescription = null, - tint = MaterialTheme.appColors.buttonText, + tint = MaterialTheme.appColors.primaryButtonText, ) Text( modifier = Modifier .testTag("txt_facebook_auth") .padding(start = 10.dp), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, text = stringResource(id = stringRes) ) } @@ -125,7 +125,7 @@ internal fun SocialAuthView( modifier = Modifier .testTag("txt_microsoft_auth") .padding(start = 10.dp), - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, text = stringResource(id = stringRes) ) } diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml index c2c34abef..9c1a1aa69 100644 --- a/auth/src/main/res/values-uk/strings.xml +++ b/auth/src/main/res/values-uk/strings.xml @@ -5,7 +5,6 @@ Електронна пошта Неправильна E-mail адреса Пароль занадто короткий - Ласкаво просимо! Будь ласка, авторизуйтесь, щоб продовжити. Показати додаткові поля Приховати додаткові поля Створити акаунт @@ -15,6 +14,5 @@ Перевірте свою електронну пошту Ми надіслали інструкції щодо відновлення пароля на вашу електронну пошту %s Введіть пароль - Створити новий аккаунт. diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 4f8ce12d8..49a8fb68e 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -11,7 +11,7 @@ Email or Username Invalid email or username Password is too short - Welcome back! Please authorize to continue. + Welcome back! Sign in to access your courses. Show optional fields Hide optional fields Create account @@ -22,8 +22,11 @@ We have sent a password recover instructions to your email %s username@domain.com Enter email or username + Please enter your username or e-mail address and try again. + Please enter your e-mail address and try again. Enter password - Create new account. + Please enter your password and try again. + Create an account to start learning today! Complete your registration Sign in with Google Sign in with Facebook @@ -39,4 +42,6 @@ By creating an account, you agree to the %1$s and %2$s and you acknowledge that %3$s and each Member process your personal data in accordance with the %4$s. By signing in to this app, you agree to the %1$s and %2$s and you acknowledge that %3$s and each Member process your personal data in accordance with the %4$s. %2$s]]> + Show password + Hide password diff --git a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt index 4c92b317f..0f040e908 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt @@ -26,7 +26,7 @@ import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -39,7 +39,7 @@ class RestorePasswordViewModelTest { private val resourceManager = mockk() private val interactor = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() //region parameters @@ -60,7 +60,7 @@ class RestorePasswordViewModelTest { every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { resourceManager.getString(org.openedx.auth.R.string.auth_invalid_email) } returns invalidEmail every { resourceManager.getString(org.openedx.auth.R.string.auth_invalid_password) } returns invalidPassword - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() } @After @@ -71,14 +71,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset empty email validation error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(emptyEmail) } returns true every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(emptyEmail) advanceUntilIdle() coVerify(exactly = 0) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -89,14 +89,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset invalid email validation error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(invalidEmail) } returns true every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(invalidEmail) advanceUntilIdle() coVerify(exactly = 0) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -107,14 +107,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset validation error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } throws EdxError.ValidationException("error") every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -125,14 +125,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset no internet error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } throws UnknownHostException() every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -143,14 +143,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset unknown error`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } throws Exception() every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -161,14 +161,14 @@ class RestorePasswordViewModelTest { @Test fun `unSuccess restore password`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } returns false every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -180,14 +180,14 @@ class RestorePasswordViewModelTest { @Test fun `success restore password`() = runTest { val viewModel = - RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + RestorePasswordViewModel(interactor, resourceManager, analytics, appNotifier) coEvery { interactor.passwordReset(correctEmail) } returns true every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } val state = viewModel.uiState.value as? RestorePasswordUIState.Success val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index b36aabb10..a46b371c8 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -38,7 +38,9 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppEvent +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.SignInEvent import java.net.UnknownHostException import org.openedx.core.R as CoreRes @@ -56,7 +58,7 @@ class SignInViewModelTest { private val preferencesManager = mockk() private val interactor = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() private val agreementProvider = mockk() private val oAuthHelper = mockk() private val router = mockk() @@ -78,13 +80,14 @@ class SignInViewModelTest { every { resourceManager.getString(CoreRes.string.core_error_unknown_error) } returns somethingWrong every { resourceManager.getString(R.string.auth_invalid_email_username) } returns invalidEmailOrUsername every { resourceManager.getString(R.string.auth_invalid_password) } returns invalidPassword - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() every { agreementProvider.getAgreement(true) } returns null every { config.isPreLoginExperienceEnabled() } returns false every { config.isSocialAuthEnabled() } returns false every { config.getFacebookConfig() } returns FacebookConfig() every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() + every { analytics.logScreenEvent(any(), any()) } returns Unit } @After @@ -104,7 +107,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -117,6 +120,7 @@ class SignInViewModelTest { coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -137,7 +141,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -171,7 +175,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -204,7 +208,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -218,6 +222,7 @@ class SignInViewModelTest { coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -233,13 +238,14 @@ class SignInViewModelTest { every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit + coEvery { appNotifier.send(any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -255,7 +261,8 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 1) { analytics.setUserIdForSession(any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } + verify(exactly = 1) { appNotifier.notifier } val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) assert(uiState.loginSuccess) @@ -275,7 +282,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -291,7 +298,8 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } + verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -313,7 +321,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -328,8 +336,9 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -351,7 +360,7 @@ class SignInViewModelTest { preferencesManager = preferencesManager, validator = validator, analytics = analytics, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -366,8 +375,9 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value diff --git a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt index f304f7363..84ca7cbde 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt @@ -44,7 +44,7 @@ import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.system.notifier.app.AppNotifier import java.net.UnknownHostException @ExperimentalCoroutinesApi @@ -59,7 +59,7 @@ class SignUpViewModelTest { private val preferencesManager = mockk() private val interactor = mockk() private val analytics = mockk() - private val appUpgradeNotifier = mockk() + private val appNotifier = mockk() private val agreementProvider = mockk() private val oAuthHelper = mockk() private val router = mockk() @@ -75,26 +75,28 @@ class SignUpViewModelTest { private val listOfFields = listOf( RegistrationField( ApiConstants.EMAIL, - "", - RegistrationFieldType.TEXT, - "", - "", - true, - true, - RegistrationField.Restrictions(), - emptyList() + label = "", + type = RegistrationFieldType.TEXT, + placeholder = "", + instructions = "", + exposed = true, + required = true, + defaultValue = false, + restrictions = RegistrationField.Restrictions(), + options = emptyList() ), RegistrationField( ApiConstants.PASSWORD, - "", - RegistrationFieldType.TEXT, - "", - "", - true, - false, - RegistrationField.Restrictions(), - emptyList() + label = "", + type = RegistrationFieldType.TEXT, + placeholder = "", + instructions = "", + exposed = true, + required = true, + defaultValue = false, + restrictions = RegistrationField.Restrictions(), + options = emptyList() ) ) @@ -111,7 +113,7 @@ class SignUpViewModelTest { every { resourceManager.getString(R.string.core_error_invalid_grant) } returns "Invalid credentials" every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { appUpgradeNotifier.notifier } returns emptyFlow() + every { appNotifier.notifier } returns emptyFlow() every { agreementProvider.getAgreement(false) } returns null every { config.isSocialAuthEnabled() } returns false every { config.getAgreement(Locale.current.language) } returns AgreementUrls() @@ -119,6 +121,7 @@ class SignUpViewModelTest { every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() + every { analytics.logScreenEvent(any(), any()) } returns Unit } @After @@ -133,7 +136,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -159,10 +162,11 @@ class SignUpViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertEquals(true, viewModel.uiState.value.validationError) assertFalse(viewModel.uiState.value.successLogin) @@ -176,7 +180,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -206,11 +210,12 @@ class SignUpViewModelTest { viewModel.register() advanceUntilIdle() verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.validationError) assertFalse(viewModel.uiState.value.successLogin) @@ -225,7 +230,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -245,10 +250,11 @@ class SignUpViewModelTest { advanceUntilIdle() verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.logEvent(any(), any()) } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.validationError) assertFalse(viewModel.uiState.value.successLogin) @@ -263,7 +269,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -298,7 +304,8 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.register(any()) } coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 2) { analytics.logEvent(any(), any()) } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.validationError) assertFalse(viewModel.uiState.value.isButtonLoading) @@ -312,7 +319,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -326,7 +333,7 @@ class SignUpViewModelTest { viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.isLoading) assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) @@ -339,7 +346,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -353,7 +360,7 @@ class SignUpViewModelTest { viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } assertFalse(viewModel.uiState.value.isLoading) assertEquals(somethingWrong, (deferred.await() as? UIMessage.SnackBarMessage)?.message) @@ -366,7 +373,7 @@ class SignUpViewModelTest { resourceManager = resourceManager, analytics = analytics, preferencesManager = preferencesManager, - appUpgradeNotifier = appUpgradeNotifier, + appNotifier = appNotifier, oAuthHelper = oAuthHelper, agreementProvider = agreementProvider, config = config, @@ -378,7 +385,7 @@ class SignUpViewModelTest { viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } - verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { appNotifier.notifier } //val fields = viewModel.uiState.value as? SignUpUIState.Fields diff --git a/build.gradle b/build.gradle index ef9ca662c..3fbc54f22 100644 --- a/build.gradle +++ b/build.gradle @@ -16,8 +16,8 @@ plugins { id 'com.android.application' version '8.4.0' apply false id 'com.android.library' version '8.4.0' apply false id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false - id 'com.google.gms.google-services' version '4.3.15' apply false - id "com.google.firebase.crashlytics" version "2.9.6" apply false + id 'com.google.gms.google-services' version '4.4.1' apply false + id "com.google.firebase.crashlytics" version "3.0.1" apply false } tasks.register('clean', Delete) { @@ -35,7 +35,7 @@ ext { media3_version = "1.1.1" youtubeplayer_version = "11.1.0" - firebase_version = "32.1.0" + firebase_version = "33.0.0" retrofit_version = '2.9.0' logginginterceptor_version = '4.9.1' @@ -56,6 +56,10 @@ ext { extented_spans_version = "1.3.0" + webkit_version = "1.11.0" + + billing_version = "6.2.1" + configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) //testing diff --git a/core/build.gradle b/core/build.gradle index f1f091823..1aed7f0a2 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -17,7 +17,7 @@ plugins { def currentFlavour = getCurrentFlavor() def config = configHelper.fetchConfig() -def platformName = config.getOrDefault("PLATFORM_NAME", "OpenEdx").toLowerCase() +def themeDirectory = config.getOrDefault("THEME_DIRECTORY", "openedx") android { compileSdk 34 @@ -50,16 +50,16 @@ android { sourceSets { prod { - java.srcDirs = ["src/$platformName"] - res.srcDirs = ["src/$platformName/res"] + java.srcDirs = ["src/$themeDirectory"] + res.srcDirs = ["src/$themeDirectory/res"] } develop { - java.srcDirs = ["src/$platformName"] - res.srcDirs = ["src/$platformName/res"] + java.srcDirs = ["src/$themeDirectory"] + res.srcDirs = ["src/$themeDirectory/res"] } stage { - java.srcDirs = ["src/$platformName"] - res.srcDirs = ["src/$platformName/res"] + java.srcDirs = ["src/$themeDirectory"] + res.srcDirs = ["src/$themeDirectory/res"] } main { assets { @@ -114,6 +114,10 @@ dependencies { api "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" api "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" + // Fullstory + api 'com.fullstory:instrumentation-full:1.47.0@aar' + api 'com.fullstory:compose:1.47.0@aar' + // Room api "androidx.room:room-runtime:$room_version" api "androidx.room:room-ktx:$room_version" @@ -154,6 +158,16 @@ dependencies { //Play In-App Review api "com.google.android.play:review-ktx:$in_app_review" + api "androidx.webkit:webkit:$webkit_version" + + // Branch SDK Integration + api "io.branch.sdk.android:library:5.9.0" + api "com.google.android.gms:play-services-ads-identifier:18.0.1" + api "com.android.installreferrer:installreferrer:2.2" + + // Google Play Billing Library + api "com.android.billingclient:billing-ktx:$billing_version" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/core/src/edx/org/openedx/core/ui/theme/Colors.kt b/core/src/edx/org/openedx/core/ui/theme/Colors.kt new file mode 100644 index 000000000..2cb72db8c --- /dev/null +++ b/core/src/edx/org/openedx/core/ui/theme/Colors.kt @@ -0,0 +1,169 @@ +package org.openedx.core.ui.theme + +import androidx.compose.ui.graphics.Color + +// Light theme colors scheme +val light_primary = Color(0xFF00262B) // Primary 500 +val light_primary_variant = Color(0xFF002121) // Primary 700 +val light_secondary = Color(0xFF1F453D) // Brand 500 +val light_secondary_variant = Color(0xFF707070) // Gray 500 +val light_background = Color.White +val light_surface = Color(0xFFFBFAF9) // Off-White +val light_error = Color(0xFFAB0D02) // Danger 500 +val light_warning = Color(0xFFF0CC00) // Warning 300 +val light_info = Color(0xFF00688D) // Info 500 +val light_info_variant = Color(0xFF1C8DBE) // Info 300 + +val light_onPrimary = Color.White +val light_onSecondary = Color.White +val light_onBackground = light_primary +val light_onSurface = Color(0xFF454545) // Gray 700 +val light_onError = Color.White +val light_onWarning = Color.White +val light_onInfo = Color.White + +val light_success_green = Color(0xFF0D7D4D)// Color(0xFFF2FAF7) // Success 500 +val light_success_background = Color(0xFFF2FAF7)//Color(0xFF0D7D4D) // Success 100 + +val light_text_primary = light_primary // Primary 500 +val light_text_primary_variant = Color(0xFF454545) // Gray 700 +val light_text_primary_light = light_secondary_variant +val light_text_hyper_link = light_info // Info 500 + +val light_text_secondary = light_primary // Primary 500 | Dark 500 | Elm +val light_text_dark = light_primary_variant // Primary 700 | Dark 700 +val light_text_warning = light_primary_variant // Primary 700 | Dark 700 + +val light_text_accent = Color(0xFF03C7E8) // Accent A Isotope Blue + +val light_text_field_background = light_surface +val light_text_field_background_variant = light_surface +val light_text_field_border = light_onSurface +val light_text_field_text = light_text_primary +val light_text_field_hint = light_secondary_variant + +val light_primary_button_background = Color(0xFFD74000) // Brand 500 +val light_primary_button_text = light_surface +val light_primary_button_border = light_primary +val light_primary_button_bordered_text = light_primary + +val light_secondary_button_background = light_primary +val light_secondary_button_text = light_background +val light_secondary_button_border = light_primary_button_background +val light_secondary_button_bordered_background = light_surface +val light_secondary_button_bordered_text = light_primary_button_background + +val light_card_view_background = light_surface +val light_card_view_border = light_text_field_border + +val light_divider = light_primary +val light_certificate_foreground = light_surface +val light_bottom_sheet_toggle = light_text_accent + +val light_rate_stars = light_warning +val light_inactive_button_background = Color(0xFFFCFCFC) +val light_access_green = Color(0xFF23BCA0) +val light_dates_section_bar_past_due = Color(0xFFFFC248) +val light_dates_section_bar_today = Color(0xFF5DE3BF) +val light_dates_section_bar_this_week = light_secondary +val light_dates_section_bar_next_week = Color(0xFF798F8B) +val light_dates_section_bar_upcoming = Color(0xFFA5B5B1) +val light_auth_sso_success_background = light_success_green +val light_auth_google_button_background = Color.White +val light_auth_facebook_button_background = Color(0xFF0866FF) +val light_auth_microsoft_button_background = Color(0xFF2E2E2E) +val light_component_horizontal_progress_completed_and_selected = light_primary +val light_component_horizontal_progress_completed = Color(0xFF8F8F8F) // Gray 400 +val light_component_horizontal_progress_selected = light_primary +val light_component_horizontal_progress_default = Color(0xFF8F8F8F) // Gray 400 + +val light_tab_unselected_btn_background = light_background +val light_tab_unselected_btn_content = light_primary +val light_tab_selected_btn_content = light_background +val light_course_home_header_shade = Color(0xFFBABABA) +val light_course_home_back_btn_background = light_surface +val light_settings_title_content = light_surface +val light_progress_bar_color = light_primary_button_background +val light_progress_bar_background_color = light_secondary_variant + + +// Dark theme colors scheme +val dark_primary = Color(0xFFFBFAF9) // Light 200 +val dark_primary_variant = Color(0xFFF2F0EF) // Light 300 +val dark_secondary = Color(0xFFD23228) // Brand 500 +val dark_secondary_variant = Color(0xFFD23228) // Brand 500 +val dark_background = Color(0xFF00262b) // Primary 500 | Dark 500 +val dark_surface = Color(0xFF1F453D) // Primary 500 | Dark 700 +val dark_error = Color(0xFFAB0D02) // Danger 500 +val dark_warning = Color(0xFFF0CC00) // Accent B Oxide Yellow +val dark_info = Color(0xFF03C7E8) // Accent A Isotope Blue +val dark_info_variant = Color(0xFF00688D) // Info 500 + +val dark_onPrimary = Color(0xFF002121) // Primary 700 | Dark 700 +val dark_onSecondary = Color.White +val dark_onBackground = dark_primary +val dark_onSurface = Color.White +val dark_onError = Color.White +val dark_onWarning = Color.White +val dark_onInfo = Color.White + +val dark_success_green = Color(0xFF0D7D4D) // success 500 +val dark_success_background = Color.White + +val dark_text_primary = dark_primary +val dark_text_primary_variant = Color(0xFFF2F0EF) // Light 300 +val dark_text_primary_light = Color(0xFF707070) // Gray 500 +val dark_text_hyper_link = Color(0xFF00688D) // Info 500 + +val dark_text_secondary = Color.White +val dark_text_dark = dark_text_primary +val dark_text_warning = Color(0xFF002121) // primary 700 + +val dark_text_accent = Color(0xFF03C7E8) // Accent A Isotope Blue +val dark_text_field_background = dark_surface +val dark_text_field_background_variant = dark_surface +val dark_text_field_border = Color(0xFFD2DAD8) // Gray 500 +val dark_text_field_text = dark_text_primary +val dark_text_field_hint = Color(0xFFD2DAD8) // Gray 700 + +val dark_primary_button_background = Color(0xFFD74000) // Primary 500 | Dark 500 | Elm +val dark_primary_button_text = Color.White +val dark_primary_button_border = dark_primary +val dark_primary_button_bordered_text = dark_primary + +val dark_secondary_button_background = dark_primary +val dark_secondary_button_text = dark_background +val dark_secondary_button_border = Color(0xFFD7D3D1) // Light 700 +val dark_secondary_button_bordered_background = Color.White +val dark_secondary_button_bordered_text = Color(0xFFD23228) // Brand 500 + +val dark_card_view_background = dark_surface +val dark_card_view_border = Color(0xFF4E5A70) +val dark_divider = dark_primary + +val dark_certificate_foreground = Color(0xD92EB865) +val dark_bottom_sheet_toggle = Color(0xFF03C7E8) // Accent A Isotope Blue +val dark_rate_stars = Color(0xFFF0CC00) // Accent B Oxide Yellow +val dark_inactive_button_background = Color(0xFFCCD4E0) +val dark_access_green = Color(0xFF23BCA0) +val dark_dates_section_bar_past_due = Color(0xFFFFC248) +val dark_dates_section_bar_today = Color(0xFF5DE3BF) +val dark_dates_section_bar_this_week = Color(0xFFA5B5B1) +val dark_dates_section_bar_next_week = Color(0xFF798F8B) +val dark_dates_section_bar_upcoming = Color(0xFF1F453D) +val dark_auth_sso_success_background = dark_success_green +val dark_auth_google_button_background = Color.White +val dark_auth_facebook_button_background = Color(0xFF0866FF) +val dark_auth_microsoft_button_background = Color(0xFF2E2E2E) +val dark_component_horizontal_progress_completed_and_selected = Color.White +val dark_component_horizontal_progress_completed = Color(0xFF8F8F8F) +val dark_component_horizontal_progress_selected = Color.White +val dark_component_horizontal_progress_default = Color(0xFF8F8F8F) +val dark_tab_unselected_btn_background = dark_background +val dark_tab_unselected_btn_content = dark_text_primary +val dark_tab_selected_btn_content = dark_background +val dark_course_home_header_shade = Color(0xFF999999) +val dark_course_home_back_btn_background = Color.Black +val dark_settings_title_content = Color.White +val dark_progress_bar_color = dark_primary_button_background +val dark_progress_bar_background_color = Color(0xFF8E9BAE) diff --git a/core/src/edx/org/openedx/core/ui/theme/LocalShapes.kt b/core/src/edx/org/openedx/core/ui/theme/LocalShapes.kt new file mode 100644 index 000000000..98e44e55e --- /dev/null +++ b/core/src/edx/org/openedx/core/ui/theme/LocalShapes.kt @@ -0,0 +1,25 @@ +package org.openedx.core.ui.theme + +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Shapes +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.dp + +internal val LocalShapes = staticCompositionLocalOf { + AppShapes( + material = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(0.dp) + ), + buttonShape = RoundedCornerShape(CornerSize(8.dp)), + navigationButtonShape = RoundedCornerShape(8.dp), + textFieldShape = RoundedCornerShape(CornerSize(8.dp)), + screenBackgroundShape = RoundedCornerShape(topStart = 30.dp, topEnd = 30.dp, bottomStart = 0.dp, bottomEnd = 0.dp), + cardShape = RoundedCornerShape(12.dp), + screenBackgroundShapeFull = RoundedCornerShape(24.dp), + courseImageShape = RoundedCornerShape(8.dp), + dialogShape = RoundedCornerShape(24.dp) + ) +} diff --git a/core/src/edx/org/openedx/core/ui/theme/compose/LogistrationLogoView.kt b/core/src/edx/org/openedx/core/ui/theme/compose/LogistrationLogoView.kt new file mode 100644 index 000000000..908ce2afb --- /dev/null +++ b/core/src/edx/org/openedx/core/ui/theme/compose/LogistrationLogoView.kt @@ -0,0 +1,35 @@ +package org.openedx.core.ui.theme.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.R +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors + +@Composable +fun LogistrationLogoView() { + Image( + modifier = Modifier + .padding(top = 64.dp, bottom = 20.dp) + .wrapContentWidth(), + painter = painterResource(id = R.drawable.core_ic_logo), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.appColors.primary), + ) +} + +@Preview(widthDp = 375) +@Composable +fun LogistrationLogoViewPreview() { + OpenEdXTheme { + LogistrationLogoView() + } +} diff --git a/core/src/edx/org/openedx/core/ui/theme/compose/SignInLogoView.kt b/core/src/edx/org/openedx/core/ui/theme/compose/SignInLogoView.kt new file mode 100644 index 000000000..f1f0a9d04 --- /dev/null +++ b/core/src/edx/org/openedx/core/ui/theme/compose/SignInLogoView.kt @@ -0,0 +1,39 @@ +package org.openedx.core.ui.theme.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.R +import org.openedx.core.ui.theme.OpenEdXTheme + +@Composable +fun SignInLogoView() { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.2f), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.core_ic_logo), + contentDescription = null, + modifier = Modifier.padding(top = 20.dp) + ) + } +} + +@Preview(widthDp = 375, heightDp = 400) +@Composable +fun SignInLogoViewPreview() { + OpenEdXTheme { + SignInLogoView() + } +} diff --git a/core/src/edx/res/drawable-night/core_top_header.png b/core/src/edx/res/drawable-night/core_top_header.png new file mode 100644 index 000000000..8e34e2d73 Binary files /dev/null and b/core/src/edx/res/drawable-night/core_top_header.png differ diff --git a/core/src/edx/res/drawable/core_ic_logo.xml b/core/src/edx/res/drawable/core_ic_logo.xml new file mode 100644 index 000000000..5b1fb514b --- /dev/null +++ b/core/src/edx/res/drawable/core_ic_logo.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/core/src/edx/res/drawable/core_top_header.png b/core/src/edx/res/drawable/core_top_header.png new file mode 100644 index 000000000..8e34e2d73 Binary files /dev/null and b/core/src/edx/res/drawable/core_top_header.png differ diff --git a/core/src/edx/res/font/black.ttf b/core/src/edx/res/font/black.ttf new file mode 100644 index 000000000..5aecf7dc4 Binary files /dev/null and b/core/src/edx/res/font/black.ttf differ diff --git a/core/src/edx/res/font/bold.ttf b/core/src/edx/res/font/bold.ttf new file mode 100644 index 000000000..8e82c70d1 Binary files /dev/null and b/core/src/edx/res/font/bold.ttf differ diff --git a/core/src/edx/res/font/extra_light.ttf b/core/src/edx/res/font/extra_light.ttf new file mode 100644 index 000000000..c993e8221 Binary files /dev/null and b/core/src/edx/res/font/extra_light.ttf differ diff --git a/core/src/edx/res/font/light.ttf b/core/src/edx/res/font/light.ttf new file mode 100644 index 000000000..71188f5cb Binary files /dev/null and b/core/src/edx/res/font/light.ttf differ diff --git a/core/src/edx/res/font/medium.ttf b/core/src/edx/res/font/medium.ttf new file mode 100644 index 000000000..b53fb1c4a Binary files /dev/null and b/core/src/edx/res/font/medium.ttf differ diff --git a/core/src/edx/res/font/regular.ttf b/core/src/edx/res/font/regular.ttf new file mode 100644 index 000000000..3b7e686e5 Binary files /dev/null and b/core/src/edx/res/font/regular.ttf differ diff --git a/core/src/edx/res/font/semi_bold.ttf b/core/src/edx/res/font/semi_bold.ttf new file mode 100644 index 000000000..556e972f4 Binary files /dev/null and b/core/src/edx/res/font/semi_bold.ttf differ diff --git a/core/src/edx/res/font/thin.ttf b/core/src/edx/res/font/thin.ttf new file mode 100644 index 000000000..fe77243fc Binary files /dev/null and b/core/src/edx/res/font/thin.ttf differ diff --git a/core/src/edx/res/values-night/colors.xml b/core/src/edx/res/values-night/colors.xml new file mode 100644 index 000000000..e26317c3a --- /dev/null +++ b/core/src/edx/res/values-night/colors.xml @@ -0,0 +1,9 @@ + + + + #FF00262b + #FFFFFF + #00262B + #F2F0EF + #707070 + diff --git a/core/src/edx/res/values/colors.xml b/core/src/edx/res/values/colors.xml new file mode 100644 index 000000000..39b83963e --- /dev/null +++ b/core/src/edx/res/values/colors.xml @@ -0,0 +1,9 @@ + + + #FFFFFF + + #00262B + #FFFFFF + #00262B + #454545 + diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index 786d63cc4..41192090e 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -34,4 +34,8 @@ object ApiConstants { const val HONOR_CODE = "honor_code" const val MARKETING_EMAILS = "marketing_emails_opt_in" } + + object IAPFields { + const val PAYMENT_PROCESSOR = "android-iap" + } } diff --git a/core/src/main/java/org/openedx/core/AppUpdateState.kt b/core/src/main/java/org/openedx/core/AppUpdateState.kt index bf347cd29..6d6a8e357 100644 --- a/core/src/main/java/org/openedx/core/AppUpdateState.kt +++ b/core/src/main/java/org/openedx/core/AppUpdateState.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.compose.runtime.mutableStateOf -import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.app.AppUpgradeEvent object AppUpdateState { var wasUpdateDialogDisplayed = false diff --git a/app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt similarity index 74% rename from app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt rename to core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt index ccbe6f715..273c53427 100644 --- a/app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt +++ b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt @@ -1,9 +1,9 @@ -package org.openedx.app.adapter +package org.openedx.core.adapter import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter -class MainNavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { +class NavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { private val fragments = ArrayList() diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 4b40fbc29..6c495a569 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -10,45 +10,49 @@ import java.io.InputStreamReader class Config(context: Context) { - private var configProperties: JsonObject - - init { - configProperties = try { - val inputStream = context.assets.open("config/config.json") - val parser = JsonParser() - val config = parser.parse(InputStreamReader(inputStream)) - config.asJsonObject - } catch (e: Exception) { - JsonObject() - } + private var configProperties: JsonObject = try { + val inputStream = context.assets.open("config/config.json") + val parser = JsonParser() + val config = parser.parse(InputStreamReader(inputStream)) + config.asJsonObject + } catch (e: Exception) { + JsonObject() + } + + fun getAppId(): String { + return getString(APPLICATION_ID, "") } fun getApiHostURL(): String { - return getString(API_HOST_URL, "") + return getString(API_HOST_URL) + } + + fun getEcommerceURL(): String { + return getString(ECOMMERCE_URL, "") } fun getUriScheme(): String { - return getString(URI_SCHEME, "") + return getString(URI_SCHEME) } fun getOAuthClientId(): String { - return getString(OAUTH_CLIENT_ID, "") + return getString(OAUTH_CLIENT_ID) } fun getAccessTokenType(): String { - return getString(TOKEN_TYPE, "") + return getString(TOKEN_TYPE) } fun getFaqUrl(): String { - return getString(FAQ_URL, "") + return getString(FAQ_URL) } fun getFeedbackEmailAddress(): String { - return getString(FEEDBACK_EMAIL_ADDRESS, "") + return getString(FEEDBACK_EMAIL_ADDRESS) } fun getPlatformName(): String { - return getString(PLATFORM_NAME, "") + return getString(PLATFORM_NAME) } fun getAgreement(locale: String): AgreementUrls { @@ -65,6 +69,10 @@ class Config(context: Context) { return getObjectOrNewInstance(SEGMENT_IO, SegmentConfig::class.java) } + fun getFullstoryConfig(): FullstoryConfig { + return getObjectOrNewInstance(FULLSTORY, FullstoryConfig::class.java) + } + fun getBrazeConfig(): BrazeConfig { return getObjectOrNewInstance(BRAZE, BrazeConfig::class.java) } @@ -91,6 +99,10 @@ class Config(context: Context) { return getObjectOrNewInstance(PROGRAM, ProgramConfig::class.java) } + fun getDashboardConfig(): DashboardConfig { + return getObjectOrNewInstance(DASHBOARD, DashboardConfig::class.java) + } + fun getBranchConfig(): BranchConfig { return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) } @@ -103,15 +115,11 @@ class Config(context: Context) { return getBoolean(PRE_LOGIN_EXPERIENCE_ENABLED, true) } - fun isCourseNestedListEnabled(): Boolean { - return getBoolean(COURSE_NESTED_LIST_ENABLED, false) - } - - fun isCourseUnitProgressEnabled(): Boolean { - return getBoolean(COURSE_UNIT_PROGRESS_ENABLED, false) + fun getCourseUIConfig(): UIConfig { + return getObjectOrNewInstance(UI_COMPONENTS, UIConfig::class.java) } - private fun getString(key: String, defaultValue: String): String { + private fun getString(key: String, defaultValue: String = ""): String { val element = getObject(key) return if (element != null) { element.asString @@ -146,7 +154,9 @@ class Config(context: Context) { } companion object { + private const val APPLICATION_ID = "APPLICATION_ID" private const val API_HOST_URL = "API_HOST_URL" + private const val ECOMMERCE_URL = "ECOMMERCE_URL" private const val URI_SCHEME = "URI_SCHEME" private const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" private const val TOKEN_TYPE = "TOKEN_TYPE" @@ -157,6 +167,7 @@ class Config(context: Context) { private const val SOCIAL_AUTH_ENABLED = "SOCIAL_AUTH_ENABLED" private const val FIREBASE = "FIREBASE" private const val SEGMENT_IO = "SEGMENT_IO" + private const val FULLSTORY = "FULLSTORY" private const val BRAZE = "BRAZE" private const val FACEBOOK = "FACEBOOK" private const val GOOGLE = "GOOGLE" @@ -164,9 +175,9 @@ class Config(context: Context) { private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" + private const val DASHBOARD = "DASHBOARD" private const val BRANCH = "BRANCH" - private const val COURSE_NESTED_LIST_ENABLED = "COURSE_NESTED_LIST_ENABLED" - private const val COURSE_UNIT_PROGRESS_ENABLED = "COURSE_UNIT_PROGRESS_ENABLED" + private const val UI_COMPONENTS = "UI_COMPONENTS" private const val PLATFORM_NAME = "PLATFORM_NAME" } diff --git a/core/src/main/java/org/openedx/core/config/DashboardConfig.kt b/core/src/main/java/org/openedx/core/config/DashboardConfig.kt new file mode 100644 index 000000000..9aa081aff --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/DashboardConfig.kt @@ -0,0 +1,16 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class DashboardConfig( + @SerializedName("TYPE") + private val viewType: String = DashboardType.GALLERY.name, +) { + fun getType(): DashboardType { + return DashboardType.valueOf(viewType.uppercase()) + } + + enum class DashboardType { + LIST, GALLERY + } +} diff --git a/core/src/main/java/org/openedx/core/config/FullstoryConfig.kt b/core/src/main/java/org/openedx/core/config/FullstoryConfig.kt new file mode 100644 index 000000000..00bc00e81 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/FullstoryConfig.kt @@ -0,0 +1,11 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class FullstoryConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = false, + + @SerializedName("ORG_ID") + private val orgId: String = "" +) diff --git a/core/src/main/java/org/openedx/core/config/ProgramConfig.kt b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt index 55714dadc..ce34365ec 100644 --- a/core/src/main/java/org/openedx/core/config/ProgramConfig.kt +++ b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt @@ -14,8 +14,8 @@ data class ProgramConfig( } data class ProgramWebViewConfig( - @SerializedName("PROGRAM_URL") + @SerializedName("BASE_URL") val programUrl: String = "", - @SerializedName("PROGRAM_DETAIL_URL_TEMPLATE") + @SerializedName("PROGRAM_DETAIL_TEMPLATE") val programDetailUrlTemplate: String = "", ) diff --git a/core/src/main/java/org/openedx/core/config/UIConfig.kt b/core/src/main/java/org/openedx/core/config/UIConfig.kt new file mode 100644 index 000000000..86c5d6b2b --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/UIConfig.kt @@ -0,0 +1,10 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class UIConfig( + @SerializedName("COURSE_DROPDOWN_NAVIGATION_ENABLED") + val isCourseDropdownNavigationEnabled: Boolean = false, + @SerializedName("COURSE_UNIT_PROGRESS_ENABLED") + val isCourseUnitProgressEnabled: Boolean = false, +) diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 4a19c383d..6d30a9044 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -67,4 +67,13 @@ interface CourseApi { @GET("/api/mobile/v1/course_info/{course_id}/updates") suspend fun getAnnouncements(@Path("course_id") courseId: String): List + + @GET("/api/mobile/v4/users/{username}/course_enrollments/") + suspend fun getUserCourses( + @Path("username") username: String, + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 20, + @Query("status") status: String? = null, + @Query("requested_fields") fields: List = emptyList() + ): CourseEnrollments } diff --git a/core/src/main/java/org/openedx/core/data/api/iap/InAppPurchasesApi.kt b/core/src/main/java/org/openedx/core/data/api/iap/InAppPurchasesApi.kt new file mode 100644 index 000000000..4730d3922 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/api/iap/InAppPurchasesApi.kt @@ -0,0 +1,33 @@ +package org.openedx.core.data.api.iap + +import org.openedx.core.data.model.iap.AddToBasketResponse +import org.openedx.core.data.model.iap.CheckoutResponse +import org.openedx.core.data.model.iap.ExecuteOrderResponse +import retrofit2.Response +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +interface InAppPurchasesApi { + @GET("/api/iap/v1/basket/add/") + suspend fun addToBasket(@Query("sku") productId: String): Response + + @FormUrlEncoded + @POST("/api/iap/v1/checkout/") + suspend fun proceedCheckout( + @Field("basket_id") basketId: Long, + @Field("payment_processor") paymentProcessor: String + ): Response + + @FormUrlEncoded + @POST("/api/iap/v1/execute/") + suspend fun executeOrder( + @Field("basket_id") basketId: Long, + @Field("payment_processor") paymentProcessor: String, + @Field("purchase_token") purchaseToken: String, + @Field("price") price: Double, + @Field("currency_code") currencyCode: String, + ): Response +} diff --git a/core/src/main/java/org/openedx/core/data/model/AppConfig.kt b/core/src/main/java/org/openedx/core/data/model/AppConfig.kt index 4fcbe3d89..218a35a4e 100644 --- a/core/src/main/java/org/openedx/core/data/model/AppConfig.kt +++ b/core/src/main/java/org/openedx/core/data/model/AppConfig.kt @@ -6,10 +6,18 @@ import org.openedx.core.domain.model.AppConfig as DomainAppConfig data class AppConfig( @SerializedName("course_dates_calendar_sync") val calendarSyncConfig: CalendarSyncConfig = CalendarSyncConfig(), + + @SerializedName("value_prop_enabled") + val isValuePropEnabled: Boolean = false, + + @SerializedName("iap_config") + val iapConfig: IAPConfig = IAPConfig(), ) { fun mapToDomain(): DomainAppConfig { return DomainAppConfig( courseDatesCalendarSync = calendarSyncConfig.mapToDomain(), + isValuePropEnabled = isValuePropEnabled, + iapConfig = iapConfig.mapToDomain(), ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt new file mode 100644 index 000000000..2ac10cb18 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt @@ -0,0 +1,26 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.AssignmentProgressDb +import org.openedx.core.domain.model.AssignmentProgress + +data class AssignmentProgress( + @SerializedName("assignment_type") + val assignmentType: String?, + @SerializedName("num_points_earned") + val numPointsEarned: Float?, + @SerializedName("num_points_possible") + val numPointsPossible: Float?, +) { + fun mapToDomain() = AssignmentProgress( + assignmentType = assignmentType ?: "", + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f + ) + + fun mapToRoomEntity() = AssignmentProgressDb( + assignmentType = assignmentType, + numPointsEarned = numPointsEarned, + numPointsPossible = numPointsPossible + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/Block.kt b/core/src/main/java/org/openedx/core/data/model/Block.kt index 9c07367ac..b5581209f 100644 --- a/core/src/main/java/org/openedx/core/data/model/Block.kt +++ b/core/src/main/java/org/openedx/core/data/model/Block.kt @@ -2,7 +2,12 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.BlockType -import org.openedx.core.domain.model.Block +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.Block as DomainBlock +import org.openedx.core.domain.model.BlockCounts as DomainBlockCounts +import org.openedx.core.domain.model.EncodedVideos as DomainEncodedVideos +import org.openedx.core.domain.model.StudentViewData as DomainStudentViewData +import org.openedx.core.domain.model.VideoInfo as DomainVideoInfo data class Block( @SerializedName("id") @@ -33,8 +38,12 @@ data class Block( val completion: Double?, @SerializedName("contains_gated_content") val containsGatedContent: Boolean?, + @SerializedName("assignment_progress") + val assignmentProgress: AssignmentProgress?, + @SerializedName("due") + val due: String? ) { - fun mapToDomain(blockData: Map): Block { + fun mapToDomain(blockData: Map): DomainBlock { val blockType = BlockType.getBlockType(type ?: "") val descendantsType = if (blockType == BlockType.VERTICAL) { val types = descendants?.map { descendant -> @@ -46,7 +55,7 @@ data class Block( blockType } - return org.openedx.core.domain.model.Block( + return DomainBlock( id = id ?: "", blockId = blockId ?: "", lmsWebUrl = lmsWebUrl ?: "", @@ -61,7 +70,9 @@ data class Block( studentViewMultiDevice = studentViewMultiDevice ?: false, blockCounts = blockCounts?.mapToDomain()!!, completion = completion ?: 0.0, - containsGatedContent = containsGatedContent ?: false + containsGatedContent = containsGatedContent ?: false, + assignmentProgress = assignmentProgress?.mapToDomain(), + due = TimeUtils.iso8601ToDate(due ?: ""), ) } } @@ -80,8 +91,8 @@ data class StudentViewData( @SerializedName("topic_id") val topicId: String? ) { - fun mapToDomain(): org.openedx.core.domain.model.StudentViewData { - return org.openedx.core.domain.model.StudentViewData( + fun mapToDomain(): DomainStudentViewData { + return DomainStudentViewData( onlyOnWeb = onlyOnWeb ?: false, duration = duration ?: "", transcripts = transcripts, @@ -106,8 +117,8 @@ data class EncodedVideos( var mobileLow: VideoInfo? ) { - fun mapToDomain(): org.openedx.core.domain.model.EncodedVideos { - return org.openedx.core.domain.model.EncodedVideos( + fun mapToDomain(): DomainEncodedVideos { + return DomainEncodedVideos( youtube = videoInfo?.mapToDomain(), hls = hls?.mapToDomain(), fallback = fallback?.mapToDomain(), @@ -124,8 +135,8 @@ data class VideoInfo( @SerializedName("file_size") var fileSize: Int? ) { - fun mapToDomain(): org.openedx.core.domain.model.VideoInfo { - return org.openedx.core.domain.model.VideoInfo( + fun mapToDomain(): DomainVideoInfo { + return DomainVideoInfo( url = url ?: "", fileSize = fileSize ?: 0 ) @@ -136,8 +147,8 @@ data class BlockCounts( @SerializedName("video") var video: Int? ) { - fun mapToDomain(): org.openedx.core.domain.model.BlockCounts { - return org.openedx.core.domain.model.BlockCounts( + fun mapToDomain(): DomainBlockCounts { + return DomainBlockCounts( video = video ?: 0 ) } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt new file mode 100644 index 000000000..1b4275f08 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt @@ -0,0 +1,22 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseAccessDetails as DomainCourseAccessDetails + +data class CourseAccessDetails( + @SerializedName("audit_access_expires") + val auditAccessExpires: String?, + @SerializedName("courseware_access") + var coursewareAccess: CoursewareAccess?, +) { + fun mapToDomain(): DomainCourseAccessDetails = + DomainCourseAccessDetails( + TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess?.mapToDomain() + ) + + fun mapToRoomEntity(): CourseAccessDetailsDb = + CourseAccessDetailsDb(auditAccessExpires, coursewareAccess?.mapToRoomEntity()) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt b/core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt new file mode 100644 index 000000000..ed8de3a4e --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt @@ -0,0 +1,30 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.CourseAssignmentsDb +import org.openedx.core.domain.model.CourseAssignments + +data class CourseAssignments( + @SerializedName("future_assignments") + val futureAssignments: List?, + @SerializedName("past_assignments") + val pastAssignments: List?, +) { + fun mapToDomain() = CourseAssignments( + futureAssignments = futureAssignments?.mapNotNull { + it.mapToDomain() + }, + pastAssignments = pastAssignments?.mapNotNull { + it.mapToDomain() + } + ) + + fun mapToRoomEntity() = CourseAssignmentsDb( + futureAssignments = futureAssignments?.mapNotNull { + it.mapToRoomEntity() + }, + pastAssignments = pastAssignments?.mapNotNull { + it.mapToRoomEntity() + } + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt index 887112845..d29e7a7ea 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt @@ -1,8 +1,13 @@ package org.openedx.core.data.model +import android.os.Parcelable import com.google.gson.annotations.SerializedName -import java.util.* +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CourseDateBlockDb +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.utils.TimeUtils +@Parcelize data class CourseDateBlock( @SerializedName("complete") val complete: Boolean = false, @@ -25,4 +30,36 @@ data class CourseDateBlock( // component blockId in-case of navigating inside the app for component available in mobile @SerializedName("first_component_block_id") val blockId: String = "", -) +) : Parcelable { + fun mapToDomain(): CourseDateBlock? { + TimeUtils.iso8601ToDate(date)?.let { + return CourseDateBlock( + complete = complete, + date = it, + assignmentType = assignmentType, + dateType = dateType, + description = description, + learnerHasAccess = learnerHasAccess, + link = link, + title = title, + blockId = blockId + ) + } ?: return null + } + + fun mapToRoomEntity(): CourseDateBlockDb? { + TimeUtils.iso8601ToDate(date)?.let { + return CourseDateBlockDb( + complete = complete, + date = it, + assignmentType = assignmentType, + dateType = dateType, + description = description, + learnerHasAccess = learnerHasAccess, + link = link, + title = title, + blockId = blockId + ) + } ?: return null + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt index 89ecdcab4..2682f957c 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt @@ -7,6 +7,7 @@ import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.annotations.SerializedName import java.lang.reflect.Type +import org.openedx.core.domain.model.CourseEnrollments as DomainCourseEnrollments data class CourseEnrollments( @SerializedName("enrollments") @@ -14,17 +15,44 @@ data class CourseEnrollments( @SerializedName("config") val configs: AppConfig, + + @SerializedName("primary") + val primary: EnrolledCourse?, ) { + fun mapToDomain() = DomainCourseEnrollments( + enrollments = enrollments.mapToDomain(), + configs = configs.mapToDomain(), + primary = primary?.mapToDomain() + ) + class Deserializer : JsonDeserializer { override fun deserialize( json: JsonElement?, typeOfT: Type?, - context: JsonDeserializationContext? + context: JsonDeserializationContext?, ): CourseEnrollments { val enrollments = deserializeEnrollments(json) val appConfig = deserializeAppConfig(json) + val primaryCourse = deserializePrimaryCourse(json) + + if (appConfig.iapConfig.productPrefix.isNotEmpty()) { + enrollments.results.forEach { courseData -> + courseData.setStoreSku(appConfig.iapConfig.productPrefix) + } + } + + return CourseEnrollments(enrollments, appConfig, primaryCourse) + } - return CourseEnrollments(enrollments, appConfig) + private fun deserializePrimaryCourse(json: JsonElement?): EnrolledCourse? { + return try { + Gson().fromJson( + (json as JsonObject).get("primary"), + EnrolledCourse::class.java + ) + } catch (ex: Exception) { + null + } } private fun deserializeEnrollments(json: JsonElement?): DashboardCourseList { diff --git a/core/src/main/java/org/openedx/core/data/model/CourseMode.kt b/core/src/main/java/org/openedx/core/data/model/CourseMode.kt new file mode 100644 index 000000000..d534d67a4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseMode.kt @@ -0,0 +1,34 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import kotlin.math.ceil + +/** + * Data class representing the mode of a course ("audit, verified etc"), with various attributes + * related to its identification and pricing. + * */ +data class CourseMode( + @SerializedName("slug") + val slug: String?, + + @SerializedName("sku") + val sku: String?, + + @SerializedName("android_sku") + val androidSku: String?, + + @SerializedName("min_price") + val price: Double?, + + var storeSku: String?, +) { + fun setStoreProductSku(storeProductPrefix: String) { + val ceilPrice = price + ?.let { ceil(it).toInt() } + ?.takeIf { it > 0 } + + if (storeProductPrefix.isNotBlank() && ceilPrice != null) { + storeSku = "$storeProductPrefix$ceilPrice" + } + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseStatus.kt b/core/src/main/java/org/openedx/core/data/model/CourseStatus.kt new file mode 100644 index 000000000..53cb028b4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseStatus.kt @@ -0,0 +1,30 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.CourseStatusDb +import org.openedx.core.domain.model.CourseStatus + +data class CourseStatus( + @SerializedName("last_visited_module_id") + val lastVisitedModuleId: String?, + @SerializedName("last_visited_module_path") + val lastVisitedModulePath: List?, + @SerializedName("last_visited_block_id") + val lastVisitedBlockId: String?, + @SerializedName("last_visited_unit_display_name") + val lastVisitedUnitDisplayName: String?, +) { + fun mapToDomain() = CourseStatus( + lastVisitedModuleId = lastVisitedModuleId ?: "", + lastVisitedModulePath = lastVisitedModulePath ?: emptyList(), + lastVisitedBlockId = lastVisitedBlockId ?: "", + lastVisitedUnitDisplayName = lastVisitedUnitDisplayName ?: "" + ) + + fun mapToRoomEntity() = CourseStatusDb( + lastVisitedModuleId = lastVisitedModuleId ?: "", + lastVisitedModulePath = lastVisitedModulePath ?: emptyList(), + lastVisitedBlockId = lastVisitedBlockId ?: "", + lastVisitedUnitDisplayName = lastVisitedUnitDisplayName ?: "" + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt index 9f22a14a0..1fca6e677 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt @@ -1,11 +1,20 @@ package org.openedx.core.data.model +import com.google.gson.Gson +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.BlockDb import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.MediaDb +import org.openedx.core.data.model.room.discovery.ProgressDb +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.EnrollmentMode +import org.openedx.core.domain.model.iap.ProductInfo import org.openedx.core.utils.TimeUtils +import java.lang.reflect.Type data class CourseStructureModel( @SerializedName("root") @@ -28,14 +37,20 @@ data class CourseStructureModel( var startType: String?, @SerializedName("end") var end: String?, - @SerializedName("courseware_access") - var coursewareAccess: CoursewareAccess?, @SerializedName("media") var media: Media?, + @SerializedName("course_access_details") + val courseAccessDetails: CourseAccessDetails, @SerializedName("certificate") val certificate: Certificate?, + @SerializedName("enrollment_details") + val enrollmentDetails: EnrollmentDetails, @SerializedName("is_self_paced") - var isSelfPaced: Boolean? + var isSelfPaced: Boolean?, + @SerializedName("course_progress") + val progress: Progress?, + @SerializedName("course_modes") + val courseModes: List?, ) { fun mapToDomain(): CourseStructure { return CourseStructure( @@ -51,10 +66,19 @@ data class CourseStructureModel( startDisplay = startDisplay ?: "", startType = startType ?: "", end = TimeUtils.iso8601ToDate(end ?: ""), - coursewareAccess = coursewareAccess?.mapToDomain(), media = media?.mapToDomain(), + courseAccessDetails = courseAccessDetails.mapToDomain(), certificate = certificate?.mapToDomain(), - isSelfPaced = isSelfPaced ?: false + isSelfPaced = isSelfPaced ?: false, + progress = progress?.mapToDomain(), + enrollmentDetails = enrollmentDetails.mapToDomain(), + productInfo = courseModes?.find { + EnrollmentMode.VERIFIED.toString().equals(it.slug, ignoreCase = true) + }?.takeIf { + it.androidSku.isNullOrEmpty().not() && it.storeSku.isNullOrEmpty().not() + }?.run { + ProductInfo(courseSku = androidSku!!, storeSku = storeSku!!) + } ) } @@ -70,10 +94,29 @@ data class CourseStructureModel( startDisplay = startDisplay ?: "", startType = startType ?: "", end = end ?: "", - coursewareAccess = coursewareAccess?.mapToRoomEntity(), media = MediaDb.createFrom(media), + courseAccessDetails = courseAccessDetails.mapToRoomEntity(), certificate = certificate?.mapToRoomEntity(), - isSelfPaced = isSelfPaced ?: false + isSelfPaced = isSelfPaced ?: false, + progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS, + enrollmentDetails = enrollmentDetails.mapToRoomEntity() ) } + + class Deserializer(val corePreferences: CorePreferences) : + JsonDeserializer { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): CourseStructureModel { + val courseStructure = Gson().fromJson(json, CourseStructureModel::class.java) + if (corePreferences.appConfig.iapConfig.productPrefix.isNullOrEmpty().not()) { + courseStructure.courseModes?.forEach { courseModes -> + courseModes.setStoreProductSku(corePreferences.appConfig.iapConfig.productPrefix!!) + } + } + return courseStructure + } + } } diff --git a/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt b/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt index 984794698..54aa5e88a 100644 --- a/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt +++ b/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt @@ -2,8 +2,12 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.domain.model.EnrollmentMode +import org.openedx.core.domain.model.iap.ProductInfo import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.Progress as ProgressDomain data class EnrolledCourse( @SerializedName("audit_access_expires") @@ -17,7 +21,15 @@ data class EnrolledCourse( @SerializedName("course") val course: EnrolledCourseData?, @SerializedName("certificate") - val certificate: Certificate? + val certificate: Certificate?, + @SerializedName("course_progress") + val progress: Progress?, + @SerializedName("course_status") + val courseStatus: CourseStatus?, + @SerializedName("course_assignments") + val courseAssignments: CourseAssignments?, + @SerializedName("course_modes") + val courseModes: List?, ) { fun mapToDomain(): EnrolledCourse { return EnrolledCourse( @@ -26,7 +38,17 @@ data class EnrolledCourse( mode = mode ?: "", isActive = isActive ?: false, course = course?.mapToDomain()!!, - certificate = certificate?.mapToDomain() + certificate = certificate?.mapToDomain(), + progress = progress?.mapToDomain() ?: ProgressDomain.DEFAULT_PROGRESS, + courseStatus = courseStatus?.mapToDomain(), + courseAssignments = courseAssignments?.mapToDomain(), + productInfo = courseModes?.find { + EnrollmentMode.VERIFIED.toString().equals(it.slug, ignoreCase = true) + }?.takeIf { + it.androidSku.isNullOrEmpty().not() && it.storeSku.isNullOrEmpty().not() + }?.run { + ProductInfo(courseSku = androidSku!!, storeSku = storeSku!!) + } ) } @@ -38,7 +60,16 @@ data class EnrolledCourse( mode = mode ?: "", isActive = isActive ?: false, course = course?.mapToRoomEntity()!!, - certificate = certificate?.mapToRoomEntity() + certificate = certificate?.mapToRoomEntity(), + progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS, + courseStatus = courseStatus?.mapToRoomEntity(), + courseAssignments = courseAssignments?.mapToRoomEntity() ) } + + fun setStoreSku(storeProductPrefix: String) { + courseModes?.forEach { + it.setStoreProductSku(storeProductPrefix) + } + } } diff --git a/core/src/main/java/org/openedx/core/data/model/EnrolledCourseData.kt b/core/src/main/java/org/openedx/core/data/model/EnrolledCourseData.kt index 4afc9ef71..714707d88 100644 --- a/core/src/main/java/org/openedx/core/data/model/EnrolledCourseData.kt +++ b/core/src/main/java/org/openedx/core/data/model/EnrolledCourseData.kt @@ -24,7 +24,7 @@ data class EnrolledCourseData( @SerializedName("end") var end: String?, @SerializedName("dynamic_upgrade_deadline") - var dynamicUpgradeDeadline: String?, + var upgradeDeadline: String?, @SerializedName("subscription_id") var subscriptionId: String?, @SerializedName("courseware_access") @@ -59,7 +59,7 @@ data class EnrolledCourseData( startDisplay = startDisplay ?: "", startType = startType ?: "", end = TimeUtils.iso8601ToDate(end ?: ""), - dynamicUpgradeDeadline = dynamicUpgradeDeadline ?: "", + upgradeDeadline = upgradeDeadline ?: "", subscriptionId = subscriptionId ?: "", coursewareAccess = coursewareAccess?.mapToDomain(), media = media?.mapToDomain(), @@ -84,7 +84,7 @@ data class EnrolledCourseData( startDisplay = startDisplay ?: "", startType = startType ?: "", end = end ?: "", - dynamicUpgradeDeadline = dynamicUpgradeDeadline ?: "", + upgradeDeadline = upgradeDeadline ?: "", subscriptionId = subscriptionId ?: "", coursewareAccess = coursewareAccess?.mapToRoomEntity(), media = MediaDb.createFrom(media), diff --git a/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt new file mode 100644 index 000000000..e1172d713 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt @@ -0,0 +1,37 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB +import org.openedx.core.utils.TimeUtils + +import org.openedx.core.domain.model.EnrollmentDetails as DomainEnrollmentDetails + +data class EnrollmentDetails( + @SerializedName("created") + var created: String?, + + @SerializedName("mode") + var mode: String?, + + @SerializedName("is_active") + var isActive: Boolean = false, + + @SerializedName("upgrade_deadline") + var upgradeDeadline: String?, +) { + fun mapToDomain(): DomainEnrollmentDetails { + return DomainEnrollmentDetails( + created = TimeUtils.iso8601ToDate(created ?: ""), + mode = mode, + isActive = isActive, + upgradeDeadline = TimeUtils.iso8601ToDate(upgradeDeadline ?: ""), + ) + } + + fun mapToRoomEntity() = EnrollmentDetailsDB( + created = created, + mode = mode, + isActive = isActive, + upgradeDeadline = upgradeDeadline, + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/IAPConfig.kt b/core/src/main/java/org/openedx/core/data/model/IAPConfig.kt new file mode 100644 index 000000000..2e9a78d91 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/IAPConfig.kt @@ -0,0 +1,28 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.IAPConfig as DomainIAPConfig + +/** + * Model class that contains the Config related to In App Purchases. + */ +data class IAPConfig( + + @SerializedName("enabled") + val isEnabled: Boolean = false, + + @SerializedName("android_product_prefix") + val productPrefix: String = "", + + @SerializedName("android_disabled_versions") + val disableVersions: List = listOf() + +) { + fun mapToDomain(): DomainIAPConfig { + return DomainIAPConfig( + isEnabled = isEnabled, + productPrefix = productPrefix, + disableVersions = disableVersions + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/Progress.kt b/core/src/main/java/org/openedx/core/data/model/Progress.kt new file mode 100644 index 000000000..d4813c14c --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/Progress.kt @@ -0,0 +1,22 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.ProgressDb +import org.openedx.core.domain.model.Progress + +data class Progress( + @SerializedName("assignments_completed") + val assignmentsCompleted: Int?, + @SerializedName("total_assignments_count") + val totalAssignmentsCount: Int?, +) { + fun mapToDomain() = Progress( + assignmentsCompleted = assignmentsCompleted ?: 0, + totalAssignmentsCount = totalAssignmentsCount ?: 0 + ) + + fun mapToRoomEntity() = ProgressDb( + assignmentsCompleted = assignmentsCompleted ?: 0, + totalAssignmentsCount = totalAssignmentsCount ?: 0 + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/User.kt b/core/src/main/java/org/openedx/core/data/model/User.kt index 99194624b..fbff8eced 100644 --- a/core/src/main/java/org/openedx/core/data/model/User.kt +++ b/core/src/main/java/org/openedx/core/data/model/User.kt @@ -15,7 +15,7 @@ data class User( ) { fun mapToDomain(): User { return User( - id, username, email, name?:"" + id, username, email, name ?: "" ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/iap/AddToBasketResponse.kt b/core/src/main/java/org/openedx/core/data/model/iap/AddToBasketResponse.kt new file mode 100644 index 000000000..6f7133ffe --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/iap/AddToBasketResponse.kt @@ -0,0 +1,13 @@ +package org.openedx.core.data.model.iap + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.iap.AddToBasketResponse as AddToBasketResponseDomain + +data class AddToBasketResponse( + @SerializedName("success") val success: String, + @SerializedName("basket_id") val basketId: Long +) { + fun mapToDomain(): AddToBasketResponseDomain { + return AddToBasketResponseDomain(basketId) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/iap/CheckoutResponse.kt b/core/src/main/java/org/openedx/core/data/model/iap/CheckoutResponse.kt new file mode 100644 index 000000000..1478df34a --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/iap/CheckoutResponse.kt @@ -0,0 +1,15 @@ +package org.openedx.core.data.model.iap + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.iap.CheckoutResponse as CheckoutResponseDomain + + +data class CheckoutResponse( + @SerializedName("payment_form_data") val paymentFormData: MutableMap, + @SerializedName("payment_page_url") val paymentPageUrl: String, + @SerializedName("payment_processor") val paymentProcessor: String +) { + fun mapToDomain(): CheckoutResponseDomain { + return CheckoutResponseDomain + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/iap/ExecuteOrderResponse.kt b/core/src/main/java/org/openedx/core/data/model/iap/ExecuteOrderResponse.kt new file mode 100644 index 000000000..9ba9d4890 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/iap/ExecuteOrderResponse.kt @@ -0,0 +1,30 @@ +package org.openedx.core.data.model.iap + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.iap.ExecuteOrderResponse +import org.openedx.core.domain.model.iap.ExecuteOrderResponse as ExecuteOrderResponseDomain + +data class ExecuteOrderResponse( + @SerializedName("order_data") val orderData: OrderData +) { + fun mapToDomain(): ExecuteOrderResponse { + return ExecuteOrderResponseDomain + } +} + +data class OrderData( + @SerializedName("billing_address") val billingAddress: String, + @SerializedName("currency") val currency: String, + @SerializedName("date_placed") val datePlaced: String, + @SerializedName("discount") val discount: String, + @SerializedName("number") val number: String, + @SerializedName("payment_processor") val paymentProcessor: String, + @SerializedName("status") val status: String, + @SerializedName("total_excl_tax") val totalExclTax: String, + @SerializedName("user") val user: User +) + +data class User( + @SerializedName("email") val email: String, + @SerializedName("username") val username: String +) diff --git a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt index b1e9a53cf..737437dd0 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt @@ -3,7 +3,18 @@ package org.openedx.core.data.model.room import androidx.room.ColumnInfo import androidx.room.Embedded import org.openedx.core.BlockType -import org.openedx.core.domain.model.* +import org.openedx.core.data.model.Block +import org.openedx.core.data.model.BlockCounts +import org.openedx.core.data.model.EncodedVideos +import org.openedx.core.data.model.StudentViewData +import org.openedx.core.data.model.VideoInfo +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.AssignmentProgress as DomainAssignmentProgress +import org.openedx.core.domain.model.Block as DomainBlock +import org.openedx.core.domain.model.BlockCounts as DomainBlockCounts +import org.openedx.core.domain.model.EncodedVideos as DomainEncodedVideos +import org.openedx.core.domain.model.StudentViewData as DomainStudentViewData +import org.openedx.core.domain.model.VideoInfo as DomainVideoInfo data class BlockDb( @ColumnInfo("id") @@ -33,9 +44,13 @@ data class BlockDb( @ColumnInfo("completion") val completion: Double, @ColumnInfo("contains_gated_content") - val containsGatedContent: Boolean + val containsGatedContent: Boolean, + @Embedded + val assignmentProgress: AssignmentProgressDb?, + @ColumnInfo("due") + val due: String? ) { - fun mapToDomain(blocks: List): Block { + fun mapToDomain(blocks: List): DomainBlock { val blockType = BlockType.getBlockType(type) val descendantsType = if (blockType == BlockType.VERTICAL) { val types = descendants.map { descendant -> @@ -47,7 +62,7 @@ data class BlockDb( blockType } - return Block( + return DomainBlock( id = id, blockId = blockId, lmsWebUrl = lmsWebUrl, @@ -62,14 +77,16 @@ data class BlockDb( descendants = descendants, descendantsType = descendantsType, completion = completion, - containsGatedContent = containsGatedContent + containsGatedContent = containsGatedContent, + assignmentProgress = assignmentProgress?.mapToDomain(), + due = TimeUtils.iso8601ToDate(due ?: ""), ) } companion object { fun createFrom( - block: org.openedx.core.data.model.Block + block: Block ): BlockDb { with(block) { return BlockDb( @@ -86,7 +103,9 @@ data class BlockDb( studentViewMultiDevice = studentViewMultiDevice ?: false, blockCounts = BlockCountsDb.createFrom(blockCounts), completion = completion ?: 0.0, - containsGatedContent = containsGatedContent ?: false + containsGatedContent = containsGatedContent ?: false, + assignmentProgress = assignmentProgress?.mapToRoomEntity(), + due = due ) } } @@ -105,8 +124,8 @@ data class StudentViewDataDb( @Embedded val encodedVideos: EncodedVideosDb? ) { - fun mapToDomain(): StudentViewData { - return StudentViewData( + fun mapToDomain(): DomainStudentViewData { + return DomainStudentViewData( onlyOnWeb, duration, transcripts, @@ -117,7 +136,7 @@ data class StudentViewDataDb( companion object { - fun createFrom(studentViewData: org.openedx.core.data.model.StudentViewData?): StudentViewDataDb { + fun createFrom(studentViewData: StudentViewData?): StudentViewDataDb { return StudentViewDataDb( onlyOnWeb = studentViewData?.onlyOnWeb ?: false, duration = studentViewData?.duration.toString(), @@ -144,9 +163,9 @@ data class EncodedVideosDb( @ColumnInfo("mobileLow") var mobileLow: VideoInfoDb? ) { - fun mapToDomain(): EncodedVideos { - return EncodedVideos( - youtube?.mapToDomain(), + fun mapToDomain(): DomainEncodedVideos { + return DomainEncodedVideos( + youtube = youtube?.mapToDomain(), hls = hls?.mapToDomain(), fallback = fallback?.mapToDomain(), desktopMp4 = desktopMp4?.mapToDomain(), @@ -156,7 +175,7 @@ data class EncodedVideosDb( } companion object { - fun createFrom(encodedVideos: org.openedx.core.data.model.EncodedVideos?): EncodedVideosDb { + fun createFrom(encodedVideos: EncodedVideos?): EncodedVideosDb { return EncodedVideosDb( youtube = VideoInfoDb.createFrom(encodedVideos?.videoInfo), hls = VideoInfoDb.createFrom(encodedVideos?.hls), @@ -176,10 +195,10 @@ data class VideoInfoDb( @ColumnInfo("fileSize") val fileSize: Int ) { - fun mapToDomain() = VideoInfo(url, fileSize) + fun mapToDomain() = DomainVideoInfo(url, fileSize) companion object { - fun createFrom(videoInfo: org.openedx.core.data.model.VideoInfo?): VideoInfoDb? { + fun createFrom(videoInfo: VideoInfo?): VideoInfoDb? { if (videoInfo == null) return null return VideoInfoDb( videoInfo.url ?: "", @@ -193,11 +212,26 @@ data class BlockCountsDb( @ColumnInfo("video") val video: Int ) { - fun mapToDomain() = BlockCounts(video) + fun mapToDomain() = DomainBlockCounts(video) companion object { - fun createFrom(blocksCounts: org.openedx.core.data.model.BlockCounts?): BlockCountsDb { + fun createFrom(blocksCounts: BlockCounts?): BlockCountsDb { return BlockCountsDb(blocksCounts?.video ?: 0) } } } + +data class AssignmentProgressDb( + @ColumnInfo("assignment_type") + val assignmentType: String?, + @ColumnInfo("num_points_earned") + val numPointsEarned: Float?, + @ColumnInfo("num_points_possible") + val numPointsPossible: Float?, +) { + fun mapToDomain() = DomainAssignmentProgress( + assignmentType = assignmentType ?: "", + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt index 90352d821..d9e50711a 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt @@ -5,7 +5,9 @@ import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey import org.openedx.core.data.model.room.discovery.CertificateDb -import org.openedx.core.data.model.room.discovery.CoursewareAccessDb +import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB +import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.CourseStructure import org.openedx.core.utils.TimeUtils @@ -33,15 +35,18 @@ data class CourseStructureEntity( @ColumnInfo("end") val end: String?, @Embedded - val coursewareAccess: CoursewareAccessDb?, - @Embedded val media: MediaDb?, @Embedded + val courseAccessDetails: CourseAccessDetailsDb, + @Embedded val certificate: CertificateDb?, + @Embedded + val enrollmentDetails: EnrollmentDetailsDB, @ColumnInfo("isSelfPaced") - val isSelfPaced: Boolean + val isSelfPaced: Boolean, + @Embedded + val progress: ProgressDb, ) { - fun mapToDomain(): CourseStructure { return CourseStructure( root, @@ -54,11 +59,13 @@ data class CourseStructureEntity( startDisplay, startType, TimeUtils.iso8601ToDate(end ?: ""), - coursewareAccess?.mapToDomain(), media?.mapToDomain(), + courseAccessDetails.mapToDomain(), certificate?.mapToDomain(), - isSelfPaced + isSelfPaced, + progress.mapToDomain(), + enrollmentDetails.mapToDomain(), + null, ) } - -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt index 05aab3bdd..fce33e00d 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt @@ -4,9 +4,21 @@ import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey +import org.openedx.core.data.model.DateType import org.openedx.core.data.model.room.MediaDb -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAccessDetails +import org.openedx.core.domain.model.CourseAssignments +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.EnrollmentDetails +import org.openedx.core.domain.model.Progress import org.openedx.core.utils.TimeUtils +import java.util.Date @Entity(tableName = "course_enrolled_table") data class EnrolledCourseEntity( @@ -25,6 +37,12 @@ data class EnrolledCourseEntity( val course: EnrolledCourseDataDb, @Embedded val certificate: CertificateDb?, + @Embedded + val progress: ProgressDb, + @Embedded + val courseStatus: CourseStatusDb?, + @Embedded + val courseAssignments: CourseAssignmentsDb?, ) { fun mapToDomain(): EnrolledCourse { @@ -34,7 +52,11 @@ data class EnrolledCourseEntity( mode, isActive, course.mapToDomain(), - certificate?.mapToDomain() + certificate?.mapToDomain(), + progress.mapToDomain(), + courseStatus?.mapToDomain(), + courseAssignments?.mapToDomain(), + null ) } } @@ -56,8 +78,8 @@ data class EnrolledCourseDataDb( val startType: String, @ColumnInfo("end") val end: String, - @ColumnInfo("dynamicUpgradeDeadline") - val dynamicUpgradeDeadline: String, + @ColumnInfo("upgradeDeadline") + val upgradeDeadline: String, @ColumnInfo("subscriptionId") val subscriptionId: String, @Embedded @@ -79,7 +101,7 @@ data class EnrolledCourseDataDb( @ColumnInfo("videoOutline") val videoOutline: String, @ColumnInfo("isSelfPaced") - val isSelfPaced: Boolean + val isSelfPaced: Boolean, ) { fun mapToDomain(): EnrolledCourseData { return EnrolledCourseData( @@ -91,7 +113,7 @@ data class EnrolledCourseDataDb( startDisplay, startType, TimeUtils.iso8601ToDate(end), - dynamicUpgradeDeadline, + upgradeDeadline, subscriptionId, coursewareAccess?.mapToDomain(), media?.mapToDomain(), @@ -119,7 +141,7 @@ data class CoursewareAccessDb( @ColumnInfo("additionalContextUserMessage") val additionalContextUserMessage: String, @ColumnInfo("userFragment") - val userFragment: String + val userFragment: String, ) { fun mapToDomain(): CoursewareAccess { @@ -137,7 +159,7 @@ data class CoursewareAccessDb( data class CertificateDb( @ColumnInfo("certificateURL") - val certificateURL: String? + val certificateURL: String?, ) { fun mapToDomain() = Certificate(certificateURL) } @@ -146,9 +168,114 @@ data class CourseSharingUtmParametersDb( @ColumnInfo("facebook") val facebook: String, @ColumnInfo("twitter") - val twitter: String + val twitter: String, ) { fun mapToDomain() = CourseSharingUtmParameters( facebook, twitter ) -} \ No newline at end of file +} + +data class ProgressDb( + @ColumnInfo("assignments_completed") + val assignmentsCompleted: Int, + @ColumnInfo("total_assignments_count") + val totalAssignmentsCount: Int, +) { + companion object { + val DEFAULT_PROGRESS = ProgressDb(0, 0) + } + + fun mapToDomain() = Progress(assignmentsCompleted, totalAssignmentsCount) +} + +data class CourseStatusDb( + @ColumnInfo("lastVisitedModuleId") + val lastVisitedModuleId: String, + @ColumnInfo("lastVisitedModulePath") + val lastVisitedModulePath: List, + @ColumnInfo("lastVisitedBlockId") + val lastVisitedBlockId: String, + @ColumnInfo("lastVisitedUnitDisplayName") + val lastVisitedUnitDisplayName: String, +) { + fun mapToDomain() = CourseStatus( + lastVisitedModuleId, lastVisitedModulePath, lastVisitedBlockId, lastVisitedUnitDisplayName + ) +} + +data class CourseAssignmentsDb( + @ColumnInfo("futureAssignments") + val futureAssignments: List?, + @ColumnInfo("pastAssignments") + val pastAssignments: List?, +) { + fun mapToDomain() = CourseAssignments( + futureAssignments = futureAssignments?.map { it.mapToDomain() }, + pastAssignments = pastAssignments?.map { it.mapToDomain() } + ) +} + +data class CourseDateBlockDb( + @ColumnInfo("title") + val title: String = "", + @ColumnInfo("description") + val description: String = "", + @ColumnInfo("link") + val link: String = "", + @ColumnInfo("blockId") + val blockId: String = "", + @ColumnInfo("learnerHasAccess") + val learnerHasAccess: Boolean = false, + @ColumnInfo("complete") + val complete: Boolean = false, + @Embedded + val date: Date, + @ColumnInfo("dateType") + val dateType: DateType = DateType.NONE, + @ColumnInfo("assignmentType") + val assignmentType: String? = "", +) { + fun mapToDomain() = CourseDateBlock( + title = title, + description = description, + link = link, + blockId = blockId, + learnerHasAccess = learnerHasAccess, + complete = complete, + date = date, + dateType = dateType, + assignmentType = assignmentType + ) +} + +data class EnrollmentDetailsDB( + @ColumnInfo("created") + var created: String?, + @ColumnInfo("mode") + var mode: String?, + @ColumnInfo("isActive") + var isActive: Boolean, + @ColumnInfo("upgradeDeadline") + var upgradeDeadline: String?, +) { + fun mapToDomain() = EnrollmentDetails( + TimeUtils.iso8601ToDate(created ?: ""), + mode, + isActive, + TimeUtils.iso8601ToDate(upgradeDeadline ?: "") + ) +} + +data class CourseAccessDetailsDb( + @ColumnInfo("auditAccessExpires") + var auditAccessExpires: String?, + @Embedded + val coursewareAccess: CoursewareAccessDb?, +) { + fun mapToDomain(): CourseAccessDetails { + return CourseAccessDetails( + TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess?.mapToDomain() + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/repository/iap/IAPRepository.kt b/core/src/main/java/org/openedx/core/data/repository/iap/IAPRepository.kt new file mode 100644 index 000000000..a4b28600a --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/repository/iap/IAPRepository.kt @@ -0,0 +1,70 @@ +package org.openedx.core.data.repository.iap + +import org.openedx.core.ApiConstants +import org.openedx.core.data.api.iap.InAppPurchasesApi +import org.openedx.core.domain.model.iap.AddToBasketResponse +import org.openedx.core.domain.model.iap.CheckoutResponse +import org.openedx.core.domain.model.iap.ExecuteOrderResponse +import org.openedx.core.exception.iap.IAPException +import org.openedx.core.exception.iap.getMessage +import org.openedx.core.presentation.iap.IAPRequestType + +class IAPRepository(private val api: InAppPurchasesApi) { + + suspend fun addToBasket(courseSku: String): AddToBasketResponse { + val response = api.addToBasket(courseSku) + if (response.isSuccessful) { + response.body()?.run { + return mapToDomain() + } + } + throw IAPException( + requestType = IAPRequestType.ADD_TO_BASKET_CODE, + httpErrorCode = response.code(), + errorMessage = response.getMessage() + ) + } + + suspend fun proceedCheckout(basketId: Long): CheckoutResponse { + val response = api.proceedCheckout( + basketId = basketId, + paymentProcessor = ApiConstants.IAPFields.PAYMENT_PROCESSOR + ) + if (response.isSuccessful) { + response.body()?.run { + return mapToDomain() + } + } + throw IAPException( + requestType = IAPRequestType.CHECKOUT_CODE, + httpErrorCode = response.code(), + errorMessage = response.getMessage() + ) + } + + suspend fun executeOrder( + basketId: Long, + paymentProcessor: String, + purchaseToken: String, + price: Double, + currencyCode: String, + ): ExecuteOrderResponse { + val response = api.executeOrder( + basketId = basketId, + paymentProcessor = paymentProcessor, + purchaseToken = purchaseToken, + price = price, + currencyCode = currencyCode + ) + if (response.isSuccessful) { + response.body()?.run { + return mapToDomain() + } + } + throw IAPException( + requestType = IAPRequestType.EXECUTE_ORDER_CODE, + httpErrorCode = response.code(), + errorMessage = response.getMessage() + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 48999ab4e..29495bae8 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -7,10 +7,12 @@ import org.openedx.core.domain.model.VideoSettings interface CorePreferences { var accessToken: String var refreshToken: String + var pushToken: String var accessTokenExpiresAt: Long var user: User? var videoSettings: VideoSettings var appConfig: AppConfig + var canResetAppDirectory: Boolean fun clear() } diff --git a/core/src/main/java/org/openedx/core/domain/interactor/IAPInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/IAPInteractor.kt new file mode 100644 index 000000000..b1f2e2762 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/interactor/IAPInteractor.kt @@ -0,0 +1,125 @@ +package org.openedx.core.domain.interactor + +import androidx.fragment.app.FragmentActivity +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import org.openedx.core.ApiConstants +import org.openedx.core.data.repository.iap.IAPRepository +import org.openedx.core.domain.model.iap.ProductInfo +import org.openedx.core.exception.iap.IAPException +import org.openedx.core.extension.decodeToLong +import org.openedx.core.module.billing.BillingProcessor +import org.openedx.core.module.billing.getCourseSku +import org.openedx.core.module.billing.getPriceAmount +import org.openedx.core.presentation.iap.IAPRequestType + +class IAPInteractor( + private val billingProcessor: BillingProcessor, + private val repository: IAPRepository, +) { + suspend fun loadPrice(productId: String): ProductDetails.OneTimePurchaseOfferDetails { + val response = billingProcessor.querySyncDetails(productId) + val productDetails = response.productDetailsList?.firstOrNull()?.oneTimePurchaseOfferDetails + val billingResult = response.billingResult + + if (billingResult.responseCode == BillingResponseCode.OK) { + if (productDetails != null) { + return productDetails + } else { + throw IAPException( + requestType = IAPRequestType.NO_SKU_CODE, + httpErrorCode = billingResult.responseCode, + errorMessage = billingResult.debugMessage + ) + } + } else { + throw IAPException( + requestType = IAPRequestType.PRICE_CODE, + httpErrorCode = billingResult.responseCode, + errorMessage = billingResult.debugMessage + ) + } + } + + suspend fun addToBasket(courseSku: String): Long { + val basketResponse = repository.addToBasket(courseSku) + return basketResponse.basketId + } + + suspend fun processCheckout(basketId: Long) { + repository.proceedCheckout(basketId) + } + + suspend fun purchaseItem( + activity: FragmentActivity, + id: Long, + productInfo: ProductInfo, + purchaseListeners: BillingProcessor.PurchaseListeners, + ) { + billingProcessor.setPurchaseListener(purchaseListeners) + billingProcessor.purchaseItem(activity, id, productInfo) + } + + suspend fun executeOrder( + basketId: Long, + purchaseToken: String, + price: Double, + currencyCode: String + ) { + repository.executeOrder( + basketId = basketId, + paymentProcessor = ApiConstants.IAPFields.PAYMENT_PROCESSOR, + purchaseToken = purchaseToken, + price = price, + currencyCode = currencyCode, + ) + } + + suspend fun consumePurchase(purchaseToken: String) { + val result = billingProcessor.consumePurchase(purchaseToken) + if (result.responseCode != BillingResponseCode.OK) { + throw IAPException( + requestType = IAPRequestType.CONSUME_CODE, + httpErrorCode = result.responseCode, + errorMessage = result.debugMessage + ) + } + } + + suspend fun processUnfulfilledPurchase(userId: Long): Boolean { + val purchases = billingProcessor.queryPurchases() + val userPurchases = + purchases.filter { it.accountIdentifiers?.obfuscatedAccountId?.decodeToLong() == userId } + if (userPurchases.isNotEmpty()) { + startUnfulfilledVerification(userPurchases) + return true + } else { + purchases.forEach { + billingProcessor.consumePurchase(it.purchaseToken) + } + } + return false + } + + private suspend fun startUnfulfilledVerification(userPurchases: List) { + userPurchases.forEach { purchase -> + val productDetail = + billingProcessor.querySyncDetails(purchase.products.first()).productDetailsList?.firstOrNull() + productDetail?.oneTimePurchaseOfferDetails?.takeIf { + purchase.getCourseSku().isNullOrEmpty().not() + }?.let { oneTimeProductDetails -> + val courseSku = purchase.getCourseSku() ?: return@let + val basketId = addToBasket(courseSku) + processCheckout(basketId) + executeOrder( + basketId = basketId, + purchaseToken = purchase.purchaseToken, + price = oneTimeProductDetails.getPriceAmount(), + currencyCode = oneTimeProductDetails.priceCurrencyCode, + ) + consumePurchase(purchase.purchaseToken) + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt index 596fd0619..17ef4a5c5 100644 --- a/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt +++ b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt @@ -4,6 +4,8 @@ import java.io.Serializable data class AppConfig( val courseDatesCalendarSync: CourseDatesCalendarSync, + val isValuePropEnabled: Boolean = false, + val iapConfig: IAPConfig = IAPConfig(), ) : Serializable data class CourseDatesCalendarSync( @@ -12,3 +14,9 @@ data class CourseDatesCalendarSync( val isInstructorPacedEnabled: Boolean, val isDeepLinkEnabled: Boolean, ) : Serializable + +data class IAPConfig( + val isEnabled: Boolean = false, + val productPrefix: String? = null, + val disableVersions: List = listOf() +) : Serializable diff --git a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt new file mode 100644 index 000000000..659665bfe --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class AssignmentProgress( + val assignmentType: String, + val numPointsEarned: Float, + val numPointsPossible: Float +) diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt index 2f1766ecb..460f283ba 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Block.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt @@ -7,6 +7,7 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.utils.VideoUtil +import java.util.Date data class Block( @@ -25,7 +26,9 @@ data class Block( val descendantsType: BlockType, val completion: Double, val containsGatedContent: Boolean = false, - val downloadModel: DownloadModel? = null + val downloadModel: DownloadModel? = null, + val assignmentProgress: AssignmentProgress?, + val due: Date? ) { val isDownloadable: Boolean get() { diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt new file mode 100644 index 000000000..d7246d2e1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt @@ -0,0 +1,11 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.Date + +@Parcelize +data class CourseAccessDetails( + val auditAccessExpires: Date?, + val coursewareAccess: CoursewareAccess?, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt b/core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt new file mode 100644 index 000000000..feb039fc7 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt @@ -0,0 +1,10 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CourseAssignments( + val futureAssignments: List?, + val pastAssignments: List? +): Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt index 7e91c59fa..394ebdd56 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt @@ -1,10 +1,13 @@ package org.openedx.core.domain.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import org.openedx.core.data.model.DateType import org.openedx.core.utils.isTimeLessThan24Hours import org.openedx.core.utils.isToday import java.util.Date +@Parcelize data class CourseDateBlock( val title: String = "", val description: String = "", @@ -15,7 +18,7 @@ data class CourseDateBlock( val date: Date, val dateType: DateType = DateType.NONE, val assignmentType: String? = "", -) { +) : Parcelable { fun isCompleted(): Boolean { return complete || (dateType in setOf( DateType.COURSE_START_DATE, diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt new file mode 100644 index 000000000..6606902c2 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class CourseEnrollments( + val enrollments: DashboardCourseList, + val configs: AppConfig, + val primary: EnrolledCourse?, +) diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt b/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt new file mode 100644 index 000000000..8803c4ce2 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt @@ -0,0 +1,11 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CourseMode( + val slug: String?, + val androidSku: String?, + var storeSku: String?, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt b/core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt new file mode 100644 index 000000000..aef245f67 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt @@ -0,0 +1,12 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CourseStatus( + val lastVisitedModuleId: String, + val lastVisitedModulePath: List, + val lastVisitedBlockId: String, + val lastVisitedUnitDisplayName: String, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt b/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt index bdb3820de..8430dfdaf 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt @@ -1,5 +1,7 @@ package org.openedx.core.domain.model +import org.openedx.core.domain.model.iap.ProductInfo +import org.openedx.core.utils.TimeUtils import java.util.Date data class CourseStructure( @@ -13,8 +15,21 @@ data class CourseStructure( val startDisplay: String, val startType: String, val end: Date?, - val coursewareAccess: CoursewareAccess?, val media: Media?, + val courseAccessDetails: CourseAccessDetails, val certificate: Certificate?, - val isSelfPaced: Boolean -) + val isSelfPaced: Boolean, + val progress: Progress?, + val enrollmentDetails: EnrollmentDetails, + val productInfo: ProductInfo? +) { + private val isStarted: Boolean + get() = TimeUtils.isDatePassed(Date(), start) + + val isUpgradeable: Boolean + get() = enrollmentDetails.isAuditMode && + isStarted && + courseAccessDetails.coursewareAccess?.hasAccess == true && + enrollmentDetails.isUpgradeDeadlinePassed.not() && + productInfo != null +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt b/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt index 187c995b6..5dd48d94e 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt @@ -11,4 +11,4 @@ data class CoursewareAccess( val userMessage: String, val additionalContextUserMessage: String, val userFragment: String -) : Parcelable \ No newline at end of file +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt index 8e339b3f6..7cbc6811c 100644 --- a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt +++ b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt @@ -2,6 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.domain.model.iap.ProductInfo import java.util.Date @Parcelize @@ -12,4 +13,18 @@ data class EnrolledCourse( val isActive: Boolean, val course: EnrolledCourseData, val certificate: Certificate?, -) : Parcelable + val progress: Progress, + val courseStatus: CourseStatus?, + val courseAssignments: CourseAssignments?, + val productInfo: ProductInfo?, +) : Parcelable { + + private val isAuditMode: Boolean + get() = EnrollmentMode.AUDIT.toString().equals(mode, ignoreCase = true) + + val isUpgradeable: Boolean + get() = isAuditMode && + course.isStarted && + course.isUpgradeDeadlinePassed.not() && + productInfo != null +} diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourseData.kt b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourseData.kt index 2a66cccde..f7d5c0963 100644 --- a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourseData.kt +++ b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourseData.kt @@ -2,7 +2,8 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize -import java.util.* +import org.openedx.core.utils.TimeUtils +import java.util.Date @Parcelize data class EnrolledCourseData( @@ -14,7 +15,7 @@ data class EnrolledCourseData( val startDisplay: String, val startType: String, val end: Date?, - val dynamicUpgradeDeadline: String, + val upgradeDeadline: String, val subscriptionId: String, val coursewareAccess: CoursewareAccess?, val media: Media?, @@ -26,4 +27,10 @@ data class EnrolledCourseData( val discussionUrl: String, val videoOutline: String, val isSelfPaced: Boolean -) : Parcelable \ No newline at end of file +) : Parcelable { + val isStarted: Boolean + get() = TimeUtils.isDatePassed(Date(), start) + + val isUpgradeDeadlinePassed: Boolean + get() = TimeUtils.isDatePassed(Date(), TimeUtils.iso8601ToDate(upgradeDeadline)) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt new file mode 100644 index 000000000..1bd89d4ef --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt @@ -0,0 +1,20 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.openedx.core.utils.TimeUtils +import java.util.Date + +@Parcelize +data class EnrollmentDetails( + var created: Date?, + var mode: String?, + var isActive: Boolean, + var upgradeDeadline: Date?, +) : Parcelable { + val isUpgradeDeadlinePassed: Boolean + get() = TimeUtils.isDatePassed(Date(), upgradeDeadline) + + val isAuditMode: Boolean + get() = EnrollmentMode.AUDIT.toString().equals(mode, ignoreCase = true) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrollmentMode.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentMode.kt new file mode 100644 index 000000000..08df4208b --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentMode.kt @@ -0,0 +1,14 @@ +package org.openedx.core.domain.model + +/** + * Course Enrollment modes + */ +enum class EnrollmentMode(private val mode: String) { + AUDIT("audit"), + VERIFIED("verified"), + NONE("none"); + + override fun toString(): String { + return mode + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/Progress.kt b/core/src/main/java/org/openedx/core/domain/model/Progress.kt new file mode 100644 index 000000000..0186857c5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/Progress.kt @@ -0,0 +1,24 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Progress( + val assignmentsCompleted: Int, + val totalAssignmentsCount: Int, +) : Parcelable { + + @IgnoredOnParcel + val value: Float + get() = if (totalAssignmentsCount != 0) { + assignmentsCompleted.toFloat() / totalAssignmentsCount.toFloat() + } else { + 0f + } + + companion object { + val DEFAULT_PROGRESS = Progress(0, 0) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/RegistrationField.kt b/core/src/main/java/org/openedx/core/domain/model/RegistrationField.kt index e4892edb5..44132156a 100644 --- a/core/src/main/java/org/openedx/core/domain/model/RegistrationField.kt +++ b/core/src/main/java/org/openedx/core/domain/model/RegistrationField.kt @@ -15,7 +15,7 @@ data class RegistrationField( val restrictions: Restrictions, val options: List