diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 33e9421b4..879f8b83b 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -36,7 +36,8 @@ jobs: uses: gradle/gradle-build-action@v2.6.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - + - name: Generate mock files + run: ./gradlew generateMockedRawFile - name: Run unit tests run: ./gradlew testProdReleaseUnitTest $CI_GRADLE_ARG_PROPERTIES diff --git a/.gitignore b/.gitignore index a66b3739e..1cc8ec083 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ .cxx local.properties /.idea/ -*.log \ No newline at end of file +*.log +/config_settings.yaml diff --git a/README.md b/README.md index 5c09e9f90..1d4039253 100644 --- a/README.md +++ b/README.md @@ -5,26 +5,29 @@ Modern vision of the mobile application for the Open EdX platform from Raccoon G [Documentation](Documentation/Documentation.md) ## Building + 1. Check out the source code: - git clone https://github.com/raccoongang/educationx-app-ios.git + git clone https://github.com/openedx/openedx-app-android.git 2. Open Android Studio and choose Open an Existing Android Studio Project. -3. Choose ``educationx-app-android``. +3. Choose ``openedx-app-android``. -4. Configure the [config.yaml](config.yaml) with URLs and OAuth credentials for your Open edX instance. +4. Configure the [config.yaml](default_config/dev/config.yaml) with URLs and OAuth credentials for your Open edX instance. 5. Select the build variant ``develop``, ``stage``, or ``prod``. 6. Click the **Run** button. ## API plugin + This project uses custom APIs to improve performance and reduce the number of requests to the server. You can find the plugin with the API and installation guide [here](https://github.com/raccoongang/mobile-api-extensions). ## License + The code in this repository is licensed under the Apache-2.0 license unless otherwise noted. -Please see [LICENSE](https://github.com/raccoongang/educationx-app-android/blob/main/LICENSE) file for details. +Please see [LICENSE](https://github.com/openedx/openedx-app-android/blob/main/LICENSE) file for details. diff --git a/app/build.gradle b/app/build.gradle index 831c836b8..a0f268eb6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,18 +3,22 @@ plugins { id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' id 'kotlin-kapt' - id "com.google.firebase.crashlytics" + id 'com.google.firebase.crashlytics' } +def config = configHelper.fetchConfig() +def appId = config.getOrDefault("APPLICATION_ID", "org.openedx.app") +def platformName = config.getOrDefault("PLATFORM_NAME", "OpenEdx").toLowerCase() + android { compileSdk 34 defaultConfig { - applicationId "org.openedx.app" + applicationId appId minSdk 24 targetSdk 34 versionCode 1 - versionName "1.0" + versionName "1.0.0" resourceConfigurations += ["en", "uk"] @@ -36,10 +40,26 @@ android { } } + sourceSets { + prod { + res.srcDirs = ["src/$platformName/res"] + } + develop { + res.srcDirs = ["src/$platformName/res"] + } + stage { + res.srcDirs = ["src/$platformName/res"] + } + } + buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + + firebaseCrashlytics { + mappingFileUploadEnabled false + } } } compileOptions { @@ -80,6 +100,7 @@ dependencies { implementation project(path: ':discovery') implementation project(path: ':profile') implementation project(path: ':discussion') + implementation project(path: ':whatsnew') kapt "androidx.room:room-compiler:$room_version" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 49a1005fc..dc403e8f7 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -74,7 +74,7 @@ #-keep class com.google.gson.stream.** { *; } # Application classes that will be serialized/deserialized over Gson --keep class com.google.gson.examples.android.model.** { ; } +-keep class org.openedx.*.data.model.** { ; } # Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, # JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) @@ -93,3 +93,19 @@ -keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken ##---------------End: proguard configuration for Gson ---------- + +-keepclassmembers class * extends java.lang.Enum { + ; + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 086cf5a34..b5581c340 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + @@ -15,11 +16,13 @@ android:name=".OpenEdXApp" android:allowBackup="false" android:dataExtractionRules="@xml/data_extraction_rules" + android:enableOnBackInvokedCallback="true" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:localeConfig="@xml/locales_config" android:roundIcon="@mipmap/ic_launcher_round" + android:screenOrientation="sensor" android:supportsRtl="true" android:theme="@style/Theme.App.Starting" tools:targetApi="tiramisu"> @@ -27,7 +30,6 @@ android:name=".AppActivity" android:exported="true" android:fitsSystemWindows="true" - android:screenOrientation="portrait" android:theme="@style/Theme.App.Starting" android:windowSoftInputMode="adjustPan"> @@ -47,10 +49,20 @@ android:resource="@xml/file_provider_paths" /> - + tools:node="remove" /> + + + + - \ No newline at end of file + diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 0efcb7d54..4f152677c 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -3,25 +3,45 @@ package org.openedx.app import android.content.Context import android.os.Bundle import androidx.core.os.bundleOf -import com.google.firebase.analytics.FirebaseAnalytics +import org.openedx.app.analytics.Analytics +import org.openedx.app.analytics.FirebaseAnalytics import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.core.config.Config import org.openedx.course.presentation.CourseAnalytics -import org.openedx.dashboard.presentation.DashboardAnalytics +import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.profile.presentation.ProfileAnalytics -class AnalyticsManager(context: Context) : DashboardAnalytics, AuthAnalytics, AppAnalytics, +class AnalyticsManager( + context: Context, + config: Config, +) : DashboardAnalytics, AuthAnalytics, AppAnalytics, DiscoveryAnalytics, ProfileAnalytics, CourseAnalytics, DiscussionAnalytics { - private val analytics = FirebaseAnalytics.getInstance(context) + private val services: ArrayList = arrayListOf() + + init { + // Initialise all the analytics libraries here + if (config.getFirebaseConfig().projectId.isNotEmpty()) { + addAnalyticsTracker(FirebaseAnalytics(context = context)) + } + } + + private fun addAnalyticsTracker(analytic: Analytics) { + services.add(analytic) + } private fun logEvent(event: Event, params: Bundle = bundleOf()) { - analytics.logEvent(event.eventName, params) + services.forEach { analytics -> + analytics.logEvent(event.eventName, params) + } } private fun setUserId(userId: Long) { - analytics.setUserId(userId.toString()) + services.forEach { analytics -> + analytics.logUserId(userId) + } } override fun dashboardCourseClickedEvent(courseId: String, courseName: String) { @@ -143,10 +163,22 @@ class AnalyticsManager(context: Context) : DashboardAnalytics, AuthAnalytics, Ap logEvent(Event.PRIVACY_POLICY_CLICKED) } + override fun termsOfUseClickedEvent() { + logEvent(Event.TERMS_OF_USE_CLICKED) + } + override fun cookiePolicyClickedEvent() { logEvent(Event.COOKIE_POLICY_CLICKED) } + override fun dataSellClickedEvent() { + logEvent(Event.DATE_SELL_CLICKED) + } + + override fun faqClickedEvent() { + logEvent(Event.FAQ_CLICKED) + } + override fun emailSupportClickedEvent() { logEvent(Event.EMAIL_SUPPORT_CLICKED) } @@ -320,6 +352,15 @@ class AnalyticsManager(context: Context) : DashboardAnalytics, AuthAnalytics, Ap ) } + override fun datesTabClickedEvent(courseId: String, courseName: String) { + logEvent( + Event.DATES_TAB_CLICKED, bundleOf( + Key.COURSE_ID.keyName to courseId, + Key.COURSE_NAME.keyName to courseName + ) + ) + } + override fun handoutsTabClickedEvent(courseId: String, courseName: String) { logEvent( Event.HANDOUTS_TAB_CLICKED, bundleOf( @@ -386,7 +427,10 @@ private enum class Event(val eventName: String) { PROFILE_DELETE_ACCOUNT_CLICKED("Profile_Delete_Account_Clicked"), PROFILE_VIDEO_SETTINGS_CLICKED("Profile_Video_settings_Clicked"), PRIVACY_POLICY_CLICKED("Privacy_Policy_Clicked"), + TERMS_OF_USE_CLICKED("Terms_Of_Use_Clicked"), COOKIE_POLICY_CLICKED("Cookie_Policy_Clicked"), + DATE_SELL_CLICKED("Data_Sell_Clicked"), + FAQ_CLICKED("FAQ_Clicked"), EMAIL_SUPPORT_CLICKED("Email_Support_Clicked"), COURSE_ENROLL_CLICKED("Course_Enroll_Clicked"), COURSE_ENROLL_SUCCESS("Course_Enroll_Success"), @@ -402,6 +446,7 @@ private enum class Event(val eventName: String) { COURSE_TAB_CLICKED("Course_Outline_Course_tab_Clicked"), VIDEO_TAB_CLICKED("Course_Outline_Videos_tab_Clicked"), DISCUSSION_TAB_CLICKED("Course_Outline_Discussion_tab_Clicked"), + DATES_TAB_CLICKED("Course_Outline_Dates_tab_Clicked"), HANDOUTS_TAB_CLICKED("Course_Outline_Handouts_tab_Clicked"), DISCUSSION_ALL_POSTS_CLICKED("Discussion_All_Posts_Clicked"), DISCUSSION_FOLLOWING_CLICKED("Discussion_Following_Clicked"), diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 67981f548..8646c7491 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -1,6 +1,5 @@ package org.openedx.app -import android.content.pm.ActivityInfo import android.content.res.Configuration import android.graphics.Color import android.os.Bundle @@ -11,47 +10,51 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.fragment.app.Fragment import androidx.window.layout.WindowMetricsCalculator +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.app.databinding.ActivityAppBinding +import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.extension.requestApplyInsetsWhenAttached -import org.openedx.core.presentation.global.AppData -import org.openedx.core.presentation.global.AppDataHolder import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.presentation.global.WindowSizeHolder import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.profile.presentation.ProfileRouter -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.app.databinding.ActivityAppBinding -import org.openedx.core.data.storage.CorePreferences +import org.openedx.whatsnew.WhatsNewManager +import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment -class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder, AppDataHolder { +class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { override val topInset: Int get() = _insetTop override val bottomInset: Int get() = _insetBottom + override val cutoutInset: Int + get() = _insetCutout override val windowSize: WindowSize get() = _windowSize - override val appData: AppData - get() = AppData(BuildConfig.VERSION_NAME) - private lateinit var binding: ActivityAppBinding - private val preferencesManager by inject() private val viewModel by viewModel() + private val whatsNewManager by inject() + private val corePreferencesManager by inject() private val profileRouter by inject() private var _insetTop = 0 private var _insetBottom = 0 + private var _insetCutout = 0 private var _windowSize = WindowSize(WindowType.Compact, WindowType.Compact) override fun onSaveInstanceState(outState: Bundle) { outState.putInt(TOP_INSET, topInset) outState.putInt(BOTTOM_INSET, bottomInset) + outState.putInt(CUTOUT_INSET, cutoutInset) super.onSaveInstanceState(outState) } @@ -74,6 +77,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder, AppDataH if (savedInstanceState != null) { _insetTop = savedInstanceState.getInt(TOP_INSET, 0) _insetBottom = savedInstanceState.getInt(BOTTOM_INSET, 0) + _insetCutout = savedInstanceState.getInt(CUTOUT_INSET, 0) } window.apply { @@ -93,60 +97,71 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder, AppDataH _insetTop = insetsCompat.top _insetBottom = insetsCompat.bottom + val displayCutout = WindowInsetsCompat.toWindowInsetsCompat(insets).displayCutout + if (displayCutout != null) { + val top = displayCutout.safeInsetTop + val left = displayCutout.safeInsetLeft + val right = displayCutout.safeInsetRight + _insetCutout = maxOf(top, left, right) + } + insets } binding.root.requestApplyInsetsWhenAttached() if (savedInstanceState == null) { - if (preferencesManager.user != null) { - supportFragmentManager.beginTransaction() - .add(R.id.container, MainFragment()) - .commit() - } else { - supportFragmentManager.beginTransaction() - .add(R.id.container, SignInFragment()) - .commit() + when { + corePreferencesManager.user == null -> { + if (viewModel.isLogistrationEnabled) { + addFragment(LogistrationFragment()) + } else { + addFragment(SignInFragment()) + } + } + + whatsNewManager.shouldShowWhatsNew() -> { + addFragment(WhatsNewFragment.newInstance()) + } + + corePreferencesManager.user != null -> { + addFragment(MainFragment.newInstance()) + } } } viewModel.logoutUser.observe(this) { - profileRouter.restartApp(supportFragmentManager) + profileRouter.restartApp(supportFragmentManager, viewModel.isLogistrationEnabled) } } + private fun addFragment(fragment: Fragment) { + supportFragmentManager.beginTransaction() + .add(R.id.container, fragment) + .commit() + } + private fun computeWindowSizeClasses() { val metrics = WindowMetricsCalculator.getOrCreate() .computeCurrentWindowMetrics(this) - val widthDp = metrics.bounds.width() / - resources.displayMetrics.density + val widthDp = metrics.bounds.width() / resources.displayMetrics.density val widthWindowSize = when { widthDp < 600f -> WindowType.Compact widthDp < 840f -> WindowType.Medium else -> WindowType.Expanded } - val heightDp = metrics.bounds.height() / - resources.displayMetrics.density + val heightDp = metrics.bounds.height() / resources.displayMetrics.density val heightWindowSize = when { heightDp < 480f -> WindowType.Compact heightDp < 900f -> WindowType.Medium else -> WindowType.Expanded } _windowSize = WindowSize(widthWindowSize, heightWindowSize) - requestedOrientation = - if (widthWindowSize != WindowType.Compact && heightWindowSize != WindowType.Compact) { - ActivityInfo.SCREEN_ORIENTATION_SENSOR - } else if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR) { - ActivityInfo.SCREEN_ORIENTATION_SENSOR - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } } private fun isUsingNightModeResources(): Boolean { - return when (resources.configuration.uiMode and - Configuration.UI_MODE_NIGHT_MASK) { + return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { Configuration.UI_MODE_NIGHT_YES -> true Configuration.UI_MODE_NIGHT_NO -> false Configuration.UI_MODE_NIGHT_UNDEFINED -> false @@ -157,5 +172,6 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder, AppDataH companion object { const val TOP_INSET = "topInset" const val BOTTOM_INSET = "bottomInset" + const val CUTOUT_INSET = "cutoutInset" } } diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index ea24e9d7b..15f7be5a0 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -4,26 +4,31 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction import org.openedx.auth.presentation.AuthRouter +import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.restore.RestorePasswordFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.auth.presentation.signup.SignUpFragment import org.openedx.core.FragmentViewType -import org.openedx.profile.domain.model.Account -import org.openedx.core.domain.model.CoursewareAccess 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.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.container.NoAccessCourseContainerFragment import org.openedx.course.presentation.detail.CourseDetailsFragment import org.openedx.course.presentation.handouts.HandoutsType -import org.openedx.course.presentation.handouts.WebViewFragment -import org.openedx.discovery.presentation.search.CourseSearchFragment +import org.openedx.course.presentation.handouts.HandoutsWebViewFragment +import org.openedx.course.presentation.info.CourseInfoFragment import org.openedx.course.presentation.section.CourseSectionFragment import org.openedx.course.presentation.unit.container.CourseUnitContainerFragment import org.openedx.course.presentation.unit.video.VideoFullScreenFragment import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.dashboard.presentation.program.ProgramFragment import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.discovery.presentation.NativeDiscoveryFragment +import org.openedx.discovery.presentation.search.CourseSearchFragment import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.Thread import org.openedx.discussion.presentation.DiscussionRouter @@ -32,31 +37,63 @@ import org.openedx.discussion.presentation.responses.DiscussionResponsesFragment import org.openedx.discussion.presentation.search.DiscussionSearchThreadFragment import org.openedx.discussion.presentation.threads.DiscussionAddThreadFragment 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.anothers_account.AnothersProfileFragment import org.openedx.profile.presentation.delete.DeleteProfileFragment import org.openedx.profile.presentation.edit.EditProfileFragment +import org.openedx.profile.presentation.profile.ProfileFragment import org.openedx.profile.presentation.settings.video.VideoQualityFragment import org.openedx.profile.presentation.settings.video.VideoSettingsFragment -import java.util.* +import org.openedx.whatsnew.WhatsNewRouter +import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, DiscussionRouter, - ProfileRouter { + ProfileRouter, AppUpgradeRouter, WhatsNewRouter { //region AuthRouter - override fun navigateToMain(fm: FragmentManager) { + override fun navigateToMain(fm: FragmentManager, courseId: String?) { fm.popBackStack() fm.beginTransaction() - .replace(R.id.container, MainFragment()) + .replace(R.id.container, MainFragment.newInstance(courseId)) .commit() } - override fun navigateToSignUp(fm: FragmentManager) { - replaceFragmentWithBackStack(fm, SignUpFragment()) + override fun navigateToSignIn(fm: FragmentManager, courseId: String?) { + replaceFragmentWithBackStack(fm, SignInFragment.newInstance(courseId)) + } + + override fun navigateToSignUp(fm: FragmentManager, courseId: String?) { + replaceFragmentWithBackStack(fm, SignUpFragment.newInstance(courseId)) + } + + override fun navigateToLogistration(fm: FragmentManager, courseId: String?) { + replaceFragmentWithBackStack(fm, LogistrationFragment.newInstance(courseId)) } override fun navigateToRestorePassword(fm: FragmentManager) { replaceFragmentWithBackStack(fm, RestorePasswordFragment()) } + + override fun navigateToDiscoverCourses(fm: FragmentManager, querySearch: String) { + replaceFragmentWithBackStack(fm, NativeDiscoveryFragment.newInstance(querySearch)) + } + + override fun navigateToWhatsNew(fm: FragmentManager, courseId: String?) { + fm.popBackStack() + fm.beginTransaction() + .replace(R.id.container, WhatsNewFragment.newInstance(courseId)) + .commit() + } + + override fun clearBackStack(fm: FragmentManager) { + fm.apply { + for (fragment in fragments) { + beginTransaction().remove(fragment).commit() + } + popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + } + } //endregion //region DiscoveryRouter @@ -64,8 +101,20 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, CourseDetailsFragment.newInstance(courseId)) } - override fun navigateToCourseSearch(fm: FragmentManager) { - replaceFragmentWithBackStack(fm, CourseSearchFragment()) + override fun navigateToCourseSearch(fm: FragmentManager, querySearch: String) { + replaceFragmentWithBackStack(fm, CourseSearchFragment.newInstance(querySearch)) + } + + override fun navigateToUpgradeRequired(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, UpgradeRequiredFragment()) + } + + override fun navigateToCourseInfo( + fm: FragmentManager, + courseId: String, + infoType: String, + ) { + replaceFragmentWithBackStack(fm, CourseInfoFragment.newInstance(courseId, infoType)) } //endregion @@ -82,13 +131,15 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ) } + override fun navigateToProgramInfo(fm: FragmentManager, pathId: String) { + replaceFragmentWithBackStack(fm, ProgramFragment.newInstance(pathId)) + } + override fun navigateToNoAccess( fm: FragmentManager, - title: String, - coursewareAccess: CoursewareAccess, - auditAccessExpires: Date? + title: String ) { - replaceFragment(fm, NoAccessCourseContainerFragment.newInstance(title,coursewareAccess, auditAccessExpires)) + replaceFragment(fm, NoAccessCourseContainerFragment.newInstance(title)) } //endregion @@ -97,39 +148,56 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToCourseSubsections( fm: FragmentManager, courseId: String, - blockId: String, - title: String, - mode: CourseViewMode, + subSectionId: String, + unitId: String, + componentId: String, + mode: CourseViewMode ) { replaceFragmentWithBackStack( fm, - CourseSectionFragment.newInstance(courseId, blockId, title, mode) + CourseSectionFragment.newInstance( + courseId = courseId, + subSectionId = subSectionId, + unitId = unitId, + componentId = componentId, + mode = mode + ) ) } override fun navigateToCourseContainer( fm: FragmentManager, - blockId: String, courseId: String, - courseName: String, + unitId: String, + componentId: String, mode: CourseViewMode ) { replaceFragmentWithBackStack( fm, - CourseUnitContainerFragment.newInstance(blockId, courseId, courseName, mode) + CourseUnitContainerFragment.newInstance( + courseId = courseId, + unitId = unitId, + componentId = componentId, + mode = mode + ) ) } override fun replaceCourseContainer( fm: FragmentManager, - blockId: String, courseId: String, - courseName: String, + unitId: String, + componentId: String, mode: CourseViewMode ) { replaceFragment( fm, - CourseUnitContainerFragment.newInstance(blockId, courseId, courseName, mode), + CourseUnitContainerFragment.newInstance( + courseId = courseId, + unitId = unitId, + componentId = componentId, + mode = mode + ), FragmentTransaction.TRANSIT_FRAGMENT_FADE ) } @@ -139,11 +207,12 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di videoUrl: String, videoTime: Long, blockId: String, - courseId: String + courseId: String, + isPlaying: Boolean ) { replaceFragmentWithBackStack( fm, - VideoFullScreenFragment.newInstance(videoUrl, videoTime, blockId, courseId) + VideoFullScreenFragment.newInstance(videoUrl, videoTime, blockId, courseId, isPlaying) ) } @@ -152,11 +221,18 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di videoUrl: String, videoTime: Long, blockId: String, - courseId: String + courseId: String, + isPlaying: Boolean ) { replaceFragmentWithBackStack( fm, - YoutubeVideoFullScreenFragment.newInstance(videoUrl, videoTime, blockId, courseId) + YoutubeVideoFullScreenFragment.newInstance( + videoUrl, + videoTime, + blockId, + courseId, + isPlaying + ) ) } @@ -168,7 +244,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ) { replaceFragmentWithBackStack( fm, - WebViewFragment.newInstance(title, type.name, courseId) + HandoutsWebViewFragment.newInstance(title, type.name, courseId) ) } //endregion @@ -223,6 +299,16 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di DiscussionSearchThreadFragment.newInstance(courseId) ) } + + override fun navigateToAnothersProfile( + fm: FragmentManager, + username: String + ) { + replaceFragmentWithBackStack( + fm, + AnothersProfileFragment.newInstance(username) + ) + } //endregion //region ProfileRouter @@ -242,14 +328,21 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, DeleteProfileFragment()) } - override fun restartApp(fm: FragmentManager) { + override fun navigateToWebContent(fm: FragmentManager, title: String, url: String) { + replaceFragmentWithBackStack( + fm, + WebContentFragment.newInstance(title = title, url = url) + ) + } + + override fun restartApp(fm: FragmentManager, isLogistrationEnabled: Boolean) { fm.apply { - for (fragment in fragments) { - beginTransaction().remove(fragment).commit() + clearBackStack(this) + if (isLogistrationEnabled) { + replaceFragment(fm, LogistrationFragment()) + } else { + replaceFragment(fm, SignInFragment()) } - popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - beginTransaction().replace(R.id.container, SignInFragment()) - .commit() } } //endregion @@ -261,10 +354,22 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di .commit() } - private fun replaceFragment(fm: FragmentManager, fragment: Fragment, transaction: Int = FragmentTransaction.TRANSIT_NONE) { + private fun replaceFragment( + fm: FragmentManager, + fragment: Fragment, + transaction: Int = FragmentTransaction.TRANSIT_NONE + ) { fm.beginTransaction() .setTransition(transaction) .replace(R.id.container, fragment, fragment.javaClass.simpleName) .commit() } -} \ No newline at end of file + + //App upgrade + override fun navigateToUserProfile(fm: FragmentManager) { + fm.popBackStack() + fm.beginTransaction() + .replace(R.id.container, ProfileFragment()) + .commit() + } +} diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 002e0bd96..9092f603c 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -4,16 +4,18 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import androidx.room.RoomDatabase -import org.openedx.core.BaseViewModel -import org.openedx.core.SingleEventLiveData -import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.system.notifier.LogoutEvent 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.core.BaseViewModel +import org.openedx.core.SingleEventLiveData +import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences class AppViewModel( + private val config: Config, private val notifier: AppNotifier, private val room: RoomDatabase, private val preferencesManager: CorePreferences, @@ -21,9 +23,11 @@ class AppViewModel( private val analytics: AppAnalytics ) : BaseViewModel() { + private val _logoutUser = SingleEventLiveData() val logoutUser: LiveData get() = _logoutUser - private val _logoutUser = SingleEventLiveData() + + val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() private var logoutHandledAt: Long = 0 diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 9152a0023..2021e038f 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -2,23 +2,43 @@ package org.openedx.app import android.os.Bundle import android.view.View -import androidx.viewpager2.widget.ViewPager2 +import androidx.core.os.bundleOf +import androidx.core.view.forEach import androidx.fragment.app.Fragment -import org.openedx.core.presentation.global.viewBinding -import org.openedx.dashboard.presentation.DashboardFragment -import org.openedx.discovery.presentation.DiscoveryFragment -import org.openedx.app.adapter.MainNavigationFragmentAdapter -import org.openedx.profile.presentation.profile.ProfileFragment +import androidx.fragment.app.setFragmentResultListener +import androidx.lifecycle.lifecycleScope +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.presentation.global.app_upgrade.UpgradeRequiredFragment +import org.openedx.core.presentation.global.viewBinding +import org.openedx.dashboard.presentation.dashboard.DashboardFragment +import org.openedx.dashboard.presentation.program.ProgramFragment +import org.openedx.discovery.presentation.DiscoveryNavigator +import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.profile.presentation.profile.ProfileFragment class MainFragment : Fragment(R.layout.fragment_main) { private val binding by viewBinding(FragmentMainBinding::bind) private val analytics by inject() + private val viewModel by viewModel() + private val router by inject() private lateinit var adapter: MainNavigationFragmentAdapter + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + setFragmentResultListener(UpgradeRequiredFragment.REQUEST_KEY) { _, _ -> + binding.bottomNavView.selectedItemId = R.id.fragmentProfile + viewModel.enableBottomBar(false) + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -30,14 +50,17 @@ class MainFragment : Fragment(R.layout.fragment_main) { analytics.discoveryTabClickedEvent() binding.viewPager.setCurrentItem(0, false) } + R.id.fragmentDashboard -> { analytics.dashboardTabClickedEvent() binding.viewPager.setCurrentItem(1, false) } + R.id.fragmentPrograms -> { analytics.programsTabClickedEvent() binding.viewPager.setCurrentItem(2, false) } + R.id.fragmentProfile -> { analytics.profileTabClickedEvent() binding.viewPager.setCurrentItem(3, false) @@ -45,18 +68,65 @@ class MainFragment : Fragment(R.layout.fragment_main) { } true } + + viewModel.isBottomBarEnabled.observe(viewLifecycleOwner) { isBottomBarEnabled -> + enableBottomBar(isBottomBarEnabled) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.navigateToDiscovery.collect { shouldNavigateToDiscovery -> + if (shouldNavigateToDiscovery) { + binding.bottomNavView.selectedItemId = R.id.fragmentHome + } + } + } + + requireArguments().apply { + this.getString(ARG_COURSE_ID, null)?.let { + if (it.isNotBlank()) { + router.navigateToCourseDetail(parentFragmentManager, it) + } + } + this.putString(ARG_COURSE_ID, null) + } } private fun initViewPager() { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL binding.viewPager.offscreenPageLimit = 4 + + val discoveryFragment = DiscoveryNavigator(viewModel.isDiscoveryTypeWebView) + .getDiscoveryFragment() + val programFragment = if (viewModel.isProgramTypeWebView) { + ProgramFragment(true) + } else { + InDevelopmentFragment() + } + adapter = MainNavigationFragmentAdapter(this).apply { - addFragment(DiscoveryFragment()) + addFragment(discoveryFragment) addFragment(DashboardFragment()) - addFragment(InDevelopmentFragment()) + addFragment(programFragment) addFragment(ProfileFragment()) } binding.viewPager.adapter = adapter binding.viewPager.isUserInputEnabled = false } -} \ No newline at end of file + + private fun enableBottomBar(enable: Boolean) { + binding.bottomNavView.menu.forEach { + it.isEnabled = enable + } + } + + companion object { + private const val ARG_COURSE_ID = "courseId" + fun newInstance(courseId: String? = null): MainFragment { + val fragment = MainFragment() + fragment.arguments = bundleOf( + ARG_COURSE_ID to courseId + ) + return fragment + } + } +} diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt new file mode 100644 index 000000000..3b36cc2be --- /dev/null +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -0,0 +1,47 @@ +package org.openedx.app + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.openedx.core.BaseViewModel +import org.openedx.core.config.Config +import org.openedx.dashboard.notifier.DashboardEvent +import org.openedx.dashboard.notifier.DashboardNotifier + +class MainViewModel( + private val config: Config, + private val notifier: DashboardNotifier, +) : BaseViewModel() { + + private val _isBottomBarEnabled = MutableLiveData(true) + val isBottomBarEnabled: LiveData + get() = _isBottomBarEnabled + + private val _navigateToDiscovery = MutableSharedFlow() + val navigateToDiscovery: SharedFlow + get() = _navigateToDiscovery.asSharedFlow() + + val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() + + val isProgramTypeWebView get() = config.getProgramConfig().isViewTypeWebView() + + override fun onCreate(owner: LifecycleOwner) { + super.onCreate(owner) + notifier.notifier.onEach { + if (it is DashboardEvent.NavigationToDiscovery) { + _navigateToDiscovery.emit(true) + } + }.distinctUntilChanged().launchIn(viewModelScope) + } + + fun enableBottomBar(enable: Boolean) { + _isBottomBarEnabled.value = enable + } +} diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 7cb2b1ad5..9f1f95977 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -4,14 +4,18 @@ import android.app.Application import com.google.firebase.FirebaseOptions import com.google.firebase.ktx.Firebase import com.google.firebase.ktx.initialize +import org.koin.android.ext.android.inject +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin import org.openedx.app.di.appModule import org.openedx.app.di.networkingModule import org.openedx.app.di.screenModule -import org.koin.android.ext.koin.androidContext -import org.koin.core.context.startKoin +import org.openedx.core.config.Config class OpenEdXApp : Application() { + private val config by inject() + override fun onCreate() { super.onCreate() startKoin { @@ -22,13 +26,13 @@ class OpenEdXApp : Application() { screenModule ) } - - if (org.openedx.core.BuildConfig.FIREBASE_PROJECT_ID.isNotEmpty()) { + val firebaseConfig = config.getFirebaseConfig() + if (firebaseConfig.enabled) { val options = FirebaseOptions.Builder() - .setProjectId(org.openedx.core.BuildConfig.FIREBASE_PROJECT_ID) - .setApplicationId(getString(org.openedx.core.R.string.google_app_id)) - .setApiKey(org.openedx.core.BuildConfig.FIREBASE_API_KEY) - .setGcmSenderId(org.openedx.core.BuildConfig.FIREBASE_GCM_SENDER_ID) + .setProjectId(firebaseConfig.projectId) + .setApplicationId(firebaseConfig.applicationId) + .setApiKey(firebaseConfig.apiKey) + .setGcmSenderId(firebaseConfig.gcmSenderId) .build() Firebase.initialize(this, options) } diff --git a/app/src/main/java/org/openedx/app/analytics/Analytics.kt b/app/src/main/java/org/openedx/app/analytics/Analytics.kt new file mode 100644 index 000000000..ed34ec41a --- /dev/null +++ b/app/src/main/java/org/openedx/app/analytics/Analytics.kt @@ -0,0 +1,9 @@ +package org.openedx.app.analytics + +import android.os.Bundle + +interface Analytics { + fun logScreenEvent(screenName: String, bundle: Bundle) + fun logEvent(eventName: String, bundle: Bundle) + fun logUserId(userId: Long) +} diff --git a/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt new file mode 100644 index 000000000..6e4db40a0 --- /dev/null +++ b/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt @@ -0,0 +1,30 @@ +package org.openedx.app.analytics + +import android.content.Context +import android.os.Bundle +import android.util.Log +import com.google.firebase.analytics.FirebaseAnalytics + +class FirebaseAnalytics(context: Context) : Analytics { + + private var tracker: FirebaseAnalytics? = null + + init { + tracker = FirebaseAnalytics.getInstance(context) + Log.d("Analytics", "Firebase Builder Initialised") + } + + override fun logScreenEvent(screenName: String, bundle: Bundle) { + Log.d("Analytics", "Firebase log Screen Event: $screenName + $bundle") + } + + override fun logEvent(eventName: String, bundle: Bundle) { + tracker?.logEvent(eventName, bundle) + Log.d("Analytics", "Firebase log Event $eventName: $bundle") + } + + override fun logUserId(userId: Long) { + tracker?.setUserId(userId.toString()) + Log.d("Analytics", "Firebase User Id log Event") + } +} 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 new file mode 100644 index 000000000..4e88eec42 --- /dev/null +++ b/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt @@ -0,0 +1,44 @@ +package org.openedx.app.data.networking + +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.utils.TimeUtils +import java.util.Date + +class AppUpgradeInterceptor( + private val appUpgradeNotifier: AppUpgradeNotifier +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + val responseCode = response.code + val latestAppVersion = response.header(HEADER_APP_LATEST_VERSION) ?: "" + val lastSupportedDateString = response.header(HEADER_APP_VERSION_LAST_SUPPORTED_DATE) ?: "" + val lastSupportedDateTime = TimeUtils.iso8601WithTimeZoneToDate(lastSupportedDateString)?.time ?: 0L + runBlocking { + when { + responseCode == 426 -> { + appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + } + + BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime > Date().time -> { + appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion)) + } + + latestAppVersion.isNotEmpty() && BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime < Date().time -> { + appUpgradeNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + } + } + } + return response + } + + companion object { + const val HEADER_APP_LATEST_VERSION = "EDX-APP-LATEST-VERSION" + const val HEADER_APP_VERSION_LAST_SUPPORTED_DATE = "EDX-APP-VERSION-LAST-SUPPORTED-DATE" + } +} + 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 501208bc3..bd4aa1920 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 @@ -32,7 +32,7 @@ class HandleErrorInterceptor( return response } } - } else if (errorResponse.errorDescription != null) { + } else if (errorResponse?.errorDescription != null) { throw EdxError.ValidationException(errorResponse.errorDescription ?: "") } } catch (e: JsonSyntaxException) { 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 2e18c51eb..7b1e203e2 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 @@ -1,11 +1,17 @@ package org.openedx.app.data.networking -import org.openedx.core.ApiConstants +import android.content.Context import okhttp3.Interceptor import okhttp3.Response +import org.openedx.app.BuildConfig +import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -class HeadersInterceptor(private val preferencesManager: CorePreferences) : Interceptor { +class HeadersInterceptor( + private val context: Context, + private val config: Config, + private val preferencesManager: CorePreferences, +) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response = chain.run { proceed( @@ -14,10 +20,16 @@ class HeadersInterceptor(private val preferencesManager: CorePreferences) : Inte val token = preferencesManager.accessToken if (token.isNotEmpty()) { - addHeader("Authorization", "${ApiConstants.TOKEN_TYPE_BEARER} $token") + addHeader("Authorization", "${config.getAccessTokenType()} $token") } addHeader("Accept", "application/json") + addHeader( + "User-Agent", System.getProperty("http.agent") + " " + + context.getString(org.openedx.core.R.string.app_name) + "/" + + BuildConfig.APPLICATION_ID + "/" + + BuildConfig.VERSION_NAME + ) }.build() ) } 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 777933303..f17677f19 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 @@ -2,29 +2,46 @@ package org.openedx.app.data.networking import android.util.Log import com.google.gson.Gson -import org.openedx.auth.data.api.AuthApi -import org.openedx.auth.data.model.AuthResponse -import org.openedx.core.ApiConstants -import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.system.notifier.LogoutEvent import kotlinx.coroutines.runBlocking import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +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.auth.data.api.AuthApi +import org.openedx.auth.domain.model.AuthResponse +import org.openedx.core.ApiConstants +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.utils.TimeUtils import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.io.IOException import java.util.concurrent.TimeUnit class OauthRefreshTokenAuthenticator( + private val config: Config, private val preferencesManager: CorePreferences, private val appNotifier: AppNotifier, -) : Authenticator { +) : Authenticator, Interceptor { private val authApi: AuthApi + private var lastTokenRefreshRequestTime = 0L + + override fun intercept(chain: Interceptor.Chain): Response { + if (isTokenExpired()) { + val response = createUnauthorizedResponse(chain) + val request = authenticate(chain.connection()?.route(), response) + + return request?.let { chain.proceed(it) } ?: chain.proceed(chain.request()) + } + return chain.proceed(chain.request()) + } init { val okHttpClient = OkHttpClient.Builder().apply { @@ -35,13 +52,14 @@ class OauthRefreshTokenAuthenticator( } }.build() authApi = Retrofit.Builder() - .baseUrl(BuildConfig.BASE_URL) + .baseUrl(config.getApiHostURL()) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create(Gson())) .build() .create(AuthApi::class.java) } + @Synchronized override fun authenticate(route: Route?, response: Response): Request? { val accessToken = preferencesManager.accessToken val refreshToken = preferencesManager.refreshToken @@ -53,14 +71,14 @@ class OauthRefreshTokenAuthenticator( val errorCode = getErrorCode(response.peekBody(200).string()) if (errorCode != null) { when (errorCode) { - TOKEN_EXPIRED_ERROR_MESSAGE -> { + TOKEN_EXPIRED_ERROR_MESSAGE, JWT_TOKEN_EXPIRED -> { try { val newAuth = refreshAccessToken(refreshToken) if (newAuth != null) { return response.request.newBuilder() .header( - "Authorization", - ApiConstants.TOKEN_TYPE_BEARER + " " + newAuth.accessToken + HEADER_AUTHORIZATION, + config.getAccessTokenType() + " " + newAuth.accessToken ) .build() } else { @@ -68,30 +86,30 @@ class OauthRefreshTokenAuthenticator( if (actualToken != accessToken) { return response.request.newBuilder() .header( - "Authorization", - ApiConstants.TOKEN_TYPE_BEARER + " " + actualToken + HEADER_AUTHORIZATION, + "${config.getAccessTokenType()} $actualToken" ) .build() } return null } - } catch (e: Exception) { return null } } - TOKEN_NONEXISTENT_ERROR_MESSAGE, TOKEN_INVALID_GRANT_ERROR_MESSAGE -> { + + TOKEN_NONEXISTENT_ERROR_MESSAGE, TOKEN_INVALID_GRANT_ERROR_MESSAGE, JWT_INVALID_TOKEN -> { // Retry request with the current access_token if the original access_token used in // request does not match the current access_token. This case can occur when // asynchronous calls are made and are attempting to refresh the access_token where // one call succeeds but the other fails. https://github.com/edx/edx-app-android/pull/834 - val authHeaders = - response.request.headers["Authorization"]?.split(" ".toRegex()) + val authHeaders = response.request.headers[HEADER_AUTHORIZATION] + ?.split(" ".toRegex()) if (authHeaders?.toTypedArray()?.getOrNull(1) != accessToken) { return response.request.newBuilder() .header( - "Authorization", - ApiConstants.TOKEN_TYPE_BEARER + " " + accessToken + HEADER_AUTHORIZATION, + "${config.getAccessTokenType()} $accessToken" ).build() } @@ -99,7 +117,8 @@ class OauthRefreshTokenAuthenticator( appNotifier.send(LogoutEvent()) } } - DISABLED_USER_ERROR_MESSAGE -> { + + DISABLED_USER_ERROR_MESSAGE, JWT_DISABLED_USER_ERROR_MESSAGE -> { runBlocking { appNotifier.send(LogoutEvent()) } @@ -109,25 +128,42 @@ class OauthRefreshTokenAuthenticator( return null } + private fun isTokenExpired(): Boolean { + val time = TimeUtils.getCurrentTime() + REFRESH_TOKEN_EXPIRY_THRESHOLD + return time >= preferencesManager.accessTokenExpiresAt + } + + private fun canRequestTokenRefresh(): Boolean { + return TimeUtils.getCurrentTime() - lastTokenRefreshRequestTime > + REFRESH_TOKEN_INTERVAL_MINIMUM + } + @Throws(IOException::class) private fun refreshAccessToken(refreshToken: String): AuthResponse? { - val response = authApi.refreshAccessToken( - ApiConstants.TOKEN_TYPE_REFRESH, - BuildConfig.CLIENT_ID, - refreshToken - ).execute() - val authResponse = response.body() - if (response.isSuccessful && authResponse != null) { - val newAccessToken = authResponse.accessToken ?: "" - val newRefreshToken = authResponse.refreshToken ?: "" - - if (newAccessToken.isNotEmpty() && newRefreshToken.isNotEmpty()) { - preferencesManager.accessToken = newAccessToken - preferencesManager.refreshToken = newRefreshToken + var authResponse: AuthResponse? = null + if (canRequestTokenRefresh()) { + val response = authApi.refreshAccessToken( + ApiConstants.TOKEN_TYPE_REFRESH, + config.getOAuthClientId(), + refreshToken, + config.getAccessTokenType() + ).execute() + authResponse = response.body()?.mapToDomain() + if (response.isSuccessful && authResponse != null) { + val newAccessToken = authResponse.accessToken ?: "" + val newRefreshToken = authResponse.refreshToken ?: "" + val newExpireTime = authResponse.getTokenExpiryTime() + + if (newAccessToken.isNotEmpty() && newRefreshToken.isNotEmpty()) { + preferencesManager.accessToken = newAccessToken + preferencesManager.refreshToken = newRefreshToken + preferencesManager.accessTokenExpiresAt = newExpireTime + lastTokenRefreshRequestTime = TimeUtils.getCurrentTime() + } + } else if (response.code() == 400) { + //another refresh already in progress + Thread.sleep(1500) } - } else if (response.code() == 400) { - //another refresh already in progress - Thread.sleep(1500) } return authResponse @@ -136,17 +172,22 @@ class OauthRefreshTokenAuthenticator( private fun getErrorCode(responseBody: String): String? { try { val jsonObj = JSONObject(responseBody) - var errorCode = jsonObj.optString("error_code", "") - return if (errorCode != "") { - errorCode + if (jsonObj.has(FIELD_ERROR_CODE)) { + return jsonObj.getString(FIELD_ERROR_CODE) } else { - errorCode = jsonObj - .optJSONObject("developer_message") - ?.optString("error_code", "") ?: "" - if (errorCode != "") { - errorCode + return if (TOKEN_TYPE_JWT.equals(config.getAccessTokenType(), ignoreCase = true)) { + val errorType = + if (jsonObj.has(FIELD_DETAIL)) FIELD_DETAIL else FIELD_DEVELOPER_MESSAGE + jsonObj.getString(errorType) } else { - null + val errorCode = jsonObj + .optJSONObject(FIELD_DEVELOPER_MESSAGE) + ?.optString(FIELD_ERROR_CODE, "") ?: "" + if (errorCode != "") { + errorCode + } else { + null + } } } } catch (ex: JSONException) { @@ -155,10 +196,68 @@ class OauthRefreshTokenAuthenticator( } } + /** + * [createUnauthorizedResponse] creates an unauthorized okhttp response with the initial chain + * request for [authenticate] method of [OauthRefreshTokenAuthenticator]. The response is + * specially designed to trigger the 'Token Expired' case of the [authenticate] method so that + * it can handle the refresh logic of the access token accordingly. + * + * @param chain Chain request for authentication + * @return Custom unauthorized response builder with initial request + */ + private fun createUnauthorizedResponse(chain: Interceptor.Chain) = Response.Builder() + .code(401) + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .message("Unauthorized") + .headers(chain.request().headers) + .body(getResponseBody()) + .build() + + /** + * [getResponseBody] generates an error response body based on access token type because both + * Bearer and JWT have their own sets of errors. + * + * @return ResponseBody based on access token type + */ + private fun getResponseBody(): ResponseBody { + val tokenType = config.getAccessTokenType() + val jsonObject = if (TOKEN_TYPE_JWT.equals(tokenType, ignoreCase = true)) { + JSONObject().put("detail", JWT_TOKEN_EXPIRED) + } else { + JSONObject().put("error_code", TOKEN_EXPIRED_ERROR_MESSAGE) + } + + return jsonObject.toString().toResponseBody("application/json".toMediaType()) + } + companion object { + private const val HEADER_AUTHORIZATION = "Authorization" + private const val TOKEN_EXPIRED_ERROR_MESSAGE = "token_expired" private const val TOKEN_NONEXISTENT_ERROR_MESSAGE = "token_nonexistent" private const val TOKEN_INVALID_GRANT_ERROR_MESSAGE = "invalid_grant" private const val DISABLED_USER_ERROR_MESSAGE = "user_is_disabled" + private const val JWT_TOKEN_EXPIRED = "Token has expired." + private const val JWT_INVALID_TOKEN = "Invalid token." + private const val JWT_DISABLED_USER_ERROR_MESSAGE = "User account is disabled." + + private const val FIELD_ERROR_CODE = "error_code" + private const val FIELD_DETAIL = "detail" + private const val FIELD_DEVELOPER_MESSAGE = "developer_message" + + /** + * [REFRESH_TOKEN_EXPIRY_THRESHOLD] behave as a buffer time to be used in the expiry + * verification method of the access token to ensure that the token doesn't expire during + * an active session. + */ + private const val REFRESH_TOKEN_EXPIRY_THRESHOLD = 60 * 1000 + + /** + * [REFRESH_TOKEN_INTERVAL_MINIMUM] behave as a buffer time for refresh token network + * requests. It prevents multiple calls to refresh network requests in case of an + * unauthorized access token during async requests. + */ + private const val REFRESH_TOKEN_INTERVAL_MINIMUM = 60 * 1000 } -} \ No newline at end of file +} 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 dd7f652af..bd7eb17e5 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 @@ -2,15 +2,20 @@ package org.openedx.app.data.storage import android.content.Context import com.google.gson.Gson +import org.openedx.app.BuildConfig +import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences -import org.openedx.profile.domain.model.Account -import org.openedx.core.domain.model.User +import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.domain.model.VideoSettings +import org.openedx.profile.data.model.Account import org.openedx.profile.data.storage.ProfilePreferences +import org.openedx.whatsnew.data.storage.WhatsNewPreferences -class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences { +class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences, + WhatsNewPreferences, InAppReviewPreferences { - private val sharedPreferences = context.getSharedPreferences("org.openedx.app", Context.MODE_PRIVATE) + private val sharedPreferences = + context.getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE) private fun saveString(key: String, value: String) { sharedPreferences.edit().apply { @@ -20,11 +25,28 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences private fun getString(key: String): String = sharedPreferences.getString(key, "") ?: "" + private fun saveLong(key: String, value: Long) { + sharedPreferences.edit().apply { + putLong(key, value) + }.apply() + } + + private fun getLong(key: String): Long = sharedPreferences.getLong(key, 0L) + + private fun saveBoolean(key: String, value: Boolean) { + sharedPreferences.edit().apply { + putBoolean(key, value) + }.apply() + } + + private fun getBoolean(key: String): Boolean = sharedPreferences.getBoolean(key, false) + override fun clear() { sharedPreferences.edit().apply { remove(ACCESS_TOKEN) remove(REFRESH_TOKEN) remove(USER) + remove(EXPIRES_IN) }.apply() } @@ -40,6 +62,12 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences } get() = getString(REFRESH_TOKEN) + override var accessTokenExpiresAt: Long + set(value) { + saveLong(EXPIRES_IN, value) + } + get() = getLong(EXPIRES_IN) + override var user: User? set(value) { val userJson = Gson().toJson(value) @@ -71,11 +99,42 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences ?: VideoSettings.default } + override var lastWhatsNewVersion: String + set(value) { + saveString(LAST_WHATS_NEW_VERSION, value) + } + get() = getString(LAST_WHATS_NEW_VERSION) + + override var lastReviewVersion: InAppReviewPreferences.VersionName + set(value) { + val versionNameJson = Gson().toJson(value) + saveString(LAST_REVIEW_VERSION, versionNameJson) + } + get() { + val versionNameString = getString(LAST_REVIEW_VERSION) + return Gson().fromJson( + versionNameString, + InAppReviewPreferences.VersionName::class.java + ) + ?: InAppReviewPreferences.VersionName.default + } + + + override var wasPositiveRated: Boolean + set(value) { + saveBoolean(APP_WAS_POSITIVE_RATED, value) + } + get() = getBoolean(APP_WAS_POSITIVE_RATED) + companion object { private const val ACCESS_TOKEN = "access_token" private const val REFRESH_TOKEN = "refresh_token" + private const val EXPIRES_IN = "expires_in" private const val USER = "user" private const val ACCOUNT = "account" private const val VIDEO_SETTINGS = "video_settings" + private const val LAST_WHATS_NEW_VERSION = "last_whats_new_version" + private const val LAST_REVIEW_VERSION = "last_review_version" + private const val APP_WAS_POSITIVE_RATED = "app_was_positive_rated" } -} \ No newline at end of file +} 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 4b3da913e..c5a267ece 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -1,53 +1,76 @@ package org.openedx.app.di +import androidx.appcompat.app.AppCompatActivity import androidx.room.Room import androidx.room.RoomDatabase +import com.google.android.play.core.review.ReviewManagerFactory import com.google.gson.Gson import com.google.gson.GsonBuilder +import kotlinx.coroutines.Dispatchers +import org.koin.android.ext.koin.androidApplication +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.openedx.app.AnalyticsManager +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.room.AppDatabase +import org.openedx.app.room.DATABASE_NAME +import org.openedx.app.system.notifier.AppNotifier import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter -import org.openedx.app.data.storage.PreferencesManager +import org.openedx.auth.presentation.sso.FacebookAuthHelper +import org.openedx.auth.presentation.sso.GoogleAuthHelper +import org.openedx.auth.presentation.sso.MicrosoftAuthHelper +import org.openedx.auth.presentation.sso.OAuthHelper +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.data.storage.InAppReviewPreferences +import org.openedx.core.interfaces.EnrollInCourseInteractor import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager import org.openedx.core.module.download.FileDownloader +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.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.course.domain.interactor.CourseInteractor +import org.openedx.dashboard.notifier.DashboardNotifier import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.dashboard.presentation.DashboardAnalytics +import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.system.notifier.DiscussionNotifier -import org.openedx.app.AnalyticsManager -import org.openedx.app.AppAnalytics -import org.openedx.app.AppRouter -import org.openedx.app.room.AppDatabase -import org.openedx.app.room.DATABASE_NAME -import org.openedx.app.system.notifier.AppNotifier +import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.system.notifier.ProfileNotifier -import kotlinx.coroutines.Dispatchers -import org.koin.android.ext.koin.androidApplication -import org.koin.core.qualifier.named -import org.koin.dsl.module -import org.openedx.core.data.storage.CorePreferences -import org.openedx.profile.data.storage.ProfilePreferences +import org.openedx.whatsnew.WhatsNewManager +import org.openedx.whatsnew.WhatsNewRouter +import org.openedx.whatsnew.data.storage.WhatsNewPreferences val appModule = module { + single { Config(get()) } single { PreferencesManager(get()) } single { get() } single { get() } + single { get() } + single { get() } single { ResourceManager(get()) } - - single { AppCookieManager(get()) } + single { AppCookieManager(get(), get()) } + single { ReviewManagerFactory.create(get()) } single { GsonBuilder().create() } @@ -55,6 +78,8 @@ val appModule = module { single { CourseNotifier() } single { DiscussionNotifier() } single { ProfileNotifier() } + single { AppUpgradeNotifier() } + single { DashboardNotifier() } single { AppRouter() } single { get() } @@ -63,7 +88,8 @@ val appModule = module { single { get() } single { get() } single { get() } - + single { get() } + single { get() } single { NetworkConnection(get()) } @@ -113,9 +139,14 @@ val appModule = module { DownloadWorkerController(get(), get(), get()) } + single { AppData(versionName = BuildConfig.VERSION_NAME) } + factory { (activity: AppCompatActivity) -> AppReviewManager(activity, get(), get()) } + single { TranscriptManager(get()) } + single { WhatsNewManager(get(), get(), get(), get()) } + single { get() } - single { AnalyticsManager(get()) } + single { AnalyticsManager(get(), get()) } single { get() } single { get() } single { get() } @@ -123,4 +154,11 @@ val appModule = module { single { get() } single { get() } single { get() } -} \ No newline at end of file + + factory { FacebookAuthHelper() } + factory { GoogleAuthHelper(get()) } + factory { MicrosoftAuthHelper() } + factory { OAuthHelper(get(), get(), get()) } + + factory { CourseInteractor(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 ea8b645bd..b74deefbb 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -1,41 +1,46 @@ package org.openedx.app.di +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.koin.dsl.module +import org.openedx.app.data.networking.AppUpgradeInterceptor +import org.openedx.app.data.networking.HandleErrorInterceptor +import org.openedx.app.data.networking.HeadersInterceptor +import org.openedx.app.data.networking.OauthRefreshTokenAuthenticator import org.openedx.auth.data.api.AuthApi +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.discussion.data.api.DiscussionApi -import org.openedx.app.data.networking.HandleErrorInterceptor -import org.openedx.app.data.networking.HeadersInterceptor -import org.openedx.app.data.networking.OauthRefreshTokenAuthenticator import org.openedx.profile.data.api.ProfileApi -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import org.koin.dsl.module -import org.openedx.core.BuildConfig import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit val networkingModule = module { - single { OauthRefreshTokenAuthenticator(get(), get()) } + single { OauthRefreshTokenAuthenticator(get(), get(), get()) } single { OkHttpClient.Builder().apply { writeTimeout(60, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS) - addInterceptor(HeadersInterceptor(get())) + addInterceptor(HeadersInterceptor(get(), get(), get())) if (BuildConfig.DEBUG) { addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) } addInterceptor(HandleErrorInterceptor(get())) + addInterceptor(AppUpgradeInterceptor(get())) + addInterceptor(get()) authenticator(get()) }.build() } single { + val config = this.get() Retrofit.Builder() - .baseUrl(org.openedx.core.BuildConfig.BASE_URL) + .baseUrl(config.getApiHostURL()) .client(get()) .addConverterFactory(GsonConverterFactory.create(get())) .build() @@ -51,4 +56,4 @@ val networkingModule = module { inline fun provideApi(retrofit: Retrofit): T { return retrofit.create(T::class.java) -} \ No newline at end of file +} 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 3b48775e3..9ccac7be7 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -1,31 +1,41 @@ package org.openedx.app.di +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.openedx.app.AppViewModel +import org.openedx.app.MainViewModel import org.openedx.auth.data.repository.AuthRepository import org.openedx.auth.domain.interactor.AuthInteractor 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.profile.domain.model.Account -import org.openedx.core.presentation.dialog.SelectDialogViewModel +import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel +import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.detail.CourseDetailsViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel +import org.openedx.course.presentation.info.CourseInfoViewModel import org.openedx.course.presentation.outline.CourseOutlineViewModel -import org.openedx.discovery.presentation.search.CourseSearchViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel +import org.openedx.course.presentation.unit.html.HtmlUnitViewModel +import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel 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.dashboard.data.repository.DashboardRepository import org.openedx.dashboard.domain.interactor.DashboardInteractor -import org.openedx.dashboard.presentation.DashboardViewModel +import org.openedx.dashboard.presentation.dashboard.DashboardViewModel +import org.openedx.dashboard.presentation.program.ProgramViewModel import org.openedx.discovery.data.repository.DiscoveryRepository import org.openedx.discovery.domain.interactor.DiscoveryInteractor -import org.openedx.discovery.presentation.DiscoveryViewModel +import org.openedx.discovery.presentation.NativeDiscoveryViewModel +import org.openedx.discovery.presentation.WebViewDiscoveryViewModel +import org.openedx.discovery.presentation.search.CourseSearchViewModel import org.openedx.discussion.data.repository.DiscussionRepository import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionComment @@ -35,65 +45,227 @@ 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.app.AppViewModel 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.anothers_account.AnothersProfileViewModel import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.profile.ProfileViewModel import org.openedx.profile.presentation.settings.video.VideoQualityViewModel import org.openedx.profile.presentation.settings.video.VideoSettingsViewModel -import org.koin.androidx.viewmodel.dsl.viewModel -import org.koin.core.qualifier.named -import org.koin.dsl.module +import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { - viewModel { AppViewModel(get(), get(), get(), get(named("IODispatcher")), get()) } + viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get()) } + viewModel { MainViewModel(get(), get()) } - factory { AuthRepository(get(), get()) } + factory { AuthRepository(get(), get(), get()) } factory { AuthInteractor(get()) } factory { Validator() } - viewModel { SignInViewModel(get(), get(), get(), get(), get()) } - viewModel { SignUpViewModel(get(), get(), get(), get()) } - viewModel { RestorePasswordViewModel(get(), get(), get()) } + viewModel { (courseId: String?) -> + SignInViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + courseId, + ) + } + viewModel { (courseId: String?) -> + SignUpViewModel(get(), get(), get(), get(), get(), get(), get(), courseId) + } + viewModel { RestorePasswordViewModel(get(), get(), get(), get()) } - factory { DashboardRepository(get(), get(),get()) } + factory { DashboardRepository(get(), get(), get()) } factory { DashboardInteractor(get()) } - viewModel { DashboardViewModel(get(), get(), get(), get(), get()) } + viewModel { DashboardViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } factory { DiscoveryRepository(get(), get()) } factory { DiscoveryInteractor(get()) } - viewModel { DiscoveryViewModel(get(), get(), get(), get()) } + viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { WebViewDiscoveryViewModel(get(), get(), get()) } - factory { ProfileRepository(get(), get(), get()) } + factory { ProfileRepository(get(), get(), get(), get(), get()) } factory { ProfileInteractor(get()) } - viewModel { ProfileViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get(), get(), get()) } + viewModel { + ProfileViewModel( + appData = get(), + config = get(), + interactor = get(), + resourceManager = get(), + notifier = get(), + dispatcher = get(named("IODispatcher")), + cookieManager = get(), + workerController = get(), + analytics = get(), + appUpgradeNotifier = get(), + router = get(), + ) + } viewModel { (account: Account) -> EditProfileViewModel(get(), get(), get(), get(), account) } viewModel { VideoSettingsViewModel(get(), get()) } viewModel { VideoQualityViewModel(get(), get()) } viewModel { DeleteProfileViewModel(get(), get(), get(), get()) } + viewModel { (username: String) -> AnothersProfileViewModel(get(), get(), username) } - single { CourseRepository(get(), get(), get(),get()) } + single { CourseRepository(get(), get(), get(), get()) } factory { CourseInteractor(get()) } - viewModel { (courseId: String) -> CourseDetailsViewModel(courseId, get(), get(), get(), get(), get()) } - viewModel { (courseId: String) -> CourseContainerViewModel(courseId, get(), get(), get(), get(), get()) } - viewModel { (courseId: String) -> CourseOutlineViewModel(courseId, get(), get(), get(), get(), get(), get(), get(), get()) } - viewModel { (courseId: String) -> CourseSectionViewModel(get(), get(), get(), get(), get(), get(), get(), get(), courseId) } - viewModel { (courseId: String) -> CourseUnitContainerViewModel(get(), get(), get(), courseId) } - viewModel { (courseId: String) -> CourseVideoViewModel(courseId, get(), get(), get(), get(), get(), get(), get()) } - viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get()) } + viewModel { CourseInfoViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { (courseId: String) -> + CourseDetailsViewModel( + courseId, + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } + viewModel { (courseId: String, courseTitle: String) -> + CourseContainerViewModel( + courseId, + courseTitle, + get(), + get(), + get(), + get(), + get(), + get() + ) + } + viewModel { (courseId: String) -> + CourseOutlineViewModel( + courseId, + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } + viewModel { (courseId: String) -> + CourseSectionViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + courseId + ) + } + viewModel { (courseId: String) -> + CourseUnitContainerViewModel( + get(), + get(), + get(), + get(), + courseId + ) + } + viewModel { (courseId: String) -> + CourseVideoViewModel( + courseId, + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } + viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get()) } viewModel { (courseId: String) -> VideoUnitViewModel(courseId, get(), get(), get(), get()) } - viewModel { (courseId:String, handoutsType: String) -> HandoutsViewModel(courseId, handoutsType, get()) } - viewModel { CourseSearchViewModel(get(), get(), get()) } + viewModel { (courseId: String, blockId: String) -> + EncodedVideoUnitViewModel( + courseId, + blockId, + get(), + get(), + get(), + get(), + get(), + get() + ) + } + viewModel { (courseId: String, isSelfPaced: Boolean) -> + CourseDatesViewModel( + courseId, + isSelfPaced, + get(), + get(), + get() + ) + } + viewModel { (courseId: String, handoutsType: String) -> + HandoutsViewModel( + courseId, + get(), + handoutsType, + get() + ) + } + viewModel { CourseSearchViewModel(get(), get(), get(), get(), get()) } viewModel { SelectDialogViewModel(get()) } single { DiscussionRepository(get(), get()) } factory { DiscussionInteractor(get()) } viewModel { (courseId: String) -> DiscussionTopicsViewModel(get(), get(), get(), courseId) } - viewModel { (courseId: String, topicId: String, threadType: String) -> DiscussionThreadsViewModel(get(), get(), get(), courseId, topicId, threadType) } - viewModel { (thread: org.openedx.discussion.domain.model.Thread) -> DiscussionCommentsViewModel(get(), get(), get(), thread) } - viewModel { (comment: DiscussionComment) -> DiscussionResponsesViewModel(get(), get(), get(), comment) } + viewModel { (courseId: String, topicId: String, threadType: String) -> + DiscussionThreadsViewModel( + get(), + get(), + get(), + courseId, + topicId, + threadType + ) + } + viewModel { (thread: org.openedx.discussion.domain.model.Thread) -> + DiscussionCommentsViewModel( + get(), + get(), + get(), + thread + ) + } + viewModel { (comment: DiscussionComment) -> + DiscussionResponsesViewModel( + get(), + get(), + get(), + comment + ) + } viewModel { (courseId: String) -> DiscussionAddThreadViewModel(get(), get(), get(), courseId) } - viewModel { (courseId: String) -> DiscussionSearchThreadViewModel(get(), get(), get(), courseId) } -} \ No newline at end of file + viewModel { (courseId: String) -> + DiscussionSearchThreadViewModel( + get(), + get(), + get(), + courseId + ) + } + + viewModel { (courseId: String?) -> WhatsNewViewModel(courseId, get()) } + viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } + + viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get()) } +} diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 6b3961864..40b3e813d 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -4,13 +4,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import org.openedx.app.data.storage.PreferencesManager -import org.openedx.core.domain.model.User -import org.openedx.app.room.AppDatabase -import org.openedx.app.system.notifier.AppNotifier -import org.openedx.app.system.notifier.LogoutEvent -import org.openedx.app.AppAnalytics -import org.openedx.app.AppViewModel import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -27,6 +20,14 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.app.AppAnalytics +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.config.Config +import org.openedx.core.data.model.User @ExperimentalCoroutinesApi class AppViewModelTest { @@ -36,6 +37,7 @@ class AppViewModelTest { private val dispatcher = StandardTestDispatcher()//UnconfinedTestDispatcher() + private val config = mockk() private val notifier = mockk() private val room = mockk() private val preferencesManager = mockk() @@ -58,7 +60,8 @@ class AppViewModelTest { every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns user every { notifier.notifier } returns flow { } - val viewModel = AppViewModel(notifier, room, preferencesManager, dispatcher, analytics) + val viewModel = + AppViewModel(config, notifier, room, preferencesManager, dispatcher, analytics) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -79,7 +82,8 @@ class AppViewModelTest { every { preferencesManager.user } returns user every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit - val viewModel = AppViewModel(notifier, room, preferencesManager, dispatcher, analytics) + val viewModel = + AppViewModel(config, notifier, room, preferencesManager, dispatcher, analytics) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -87,8 +91,8 @@ class AppViewModelTest { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) advanceUntilIdle() - verify(exactly = 1) { analytics.logoutEvent(true) } - assert(viewModel.logoutUser.value != null) + verify(exactly = 1) { analytics.logoutEvent(true) } + assert(viewModel.logoutUser.value != null) } @Test @@ -102,7 +106,8 @@ class AppViewModelTest { every { preferencesManager.user } returns user every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit - val viewModel = AppViewModel(notifier, room, preferencesManager, dispatcher, analytics) + val viewModel = + AppViewModel(config, notifier, room, preferencesManager, dispatcher, analytics) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -110,12 +115,11 @@ class AppViewModelTest { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) advanceUntilIdle() - verify(exactly = 1) { analytics.logoutEvent(true) } - verify(exactly = 1) { preferencesManager.clear() } - verify(exactly = 1) { analytics.setUserIdForSession(any()) } - verify(exactly = 1) { preferencesManager.user } - verify(exactly = 1) { room.clearAllTables() } - verify(exactly = 1) { analytics.logoutEvent(true) } + verify(exactly = 1) { analytics.logoutEvent(true) } + verify(exactly = 1) { preferencesManager.clear() } + verify(exactly = 1) { analytics.setUserIdForSession(any()) } + verify(exactly = 1) { preferencesManager.user } + verify(exactly = 1) { room.clearAllTables() } + verify(exactly = 1) { analytics.logoutEvent(true) } } - -} \ No newline at end of file +} diff --git a/auth/build.gradle b/auth/build.gradle index 788bb0153..02b94a587 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -55,6 +55,12 @@ android { dependencies { implementation project(path: ':core') + implementation "androidx.credentials:credentials:1.2.0" + implementation "androidx.credentials:credentials-play-services-auth:1.2.0" + implementation "com.facebook.android:facebook-login:16.2.0" + implementation "com.google.android.gms:play-services-auth:20.7.0" + implementation "com.google.android.libraries.identity.googleid:googleid:1.1.0" + implementation 'com.microsoft.identity.client:msal:4.9.0' 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" @@ -64,5 +70,4 @@ dependencies { testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" - -} \ No newline at end of file +} diff --git a/auth/proguard-rules.pro b/auth/proguard-rules.pro index 481bb4348..82ef50a20 100644 --- a/auth/proguard-rules.pro +++ b/auth/proguard-rules.pro @@ -18,4 +18,9 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +-if class androidx.credentials.CredentialManager +-keep class androidx.credentials.playservices.** { + *; +} diff --git a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt index a8ee4bcb4..903cbd62e 100644 --- a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt +++ b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt @@ -11,17 +11,25 @@ import retrofit2.http.* interface AuthApi { + @FormUrlEncoded + @POST(ApiConstants.URL_EXCHANGE_TOKEN) + suspend fun exchangeAccessToken( + @Field("access_token") accessToken: String, + @Field("client_id") clientId: String, + @Field("token_type") tokenType: String, + @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, + @Path("auth_type") authType: String, + ): AuthResponse + @FormUrlEncoded @POST(ApiConstants.URL_ACCESS_TOKEN) suspend fun getAccessToken( - @Field("grant_type") - grantType: String, - @Field("client_id") - clientId: String, - @Field("username") - username: String, - @Field("password") - password: String, + @Field("grant_type") grantType: String, + @Field("client_id") clientId: String, + @Field("username") username: String, + @Field("password") password: String, + @Field("token_type") tokenType: String, + @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, ): AuthResponse @FormUrlEncoded @@ -30,6 +38,8 @@ interface AuthApi { @Field("grant_type") grantType: String, @Field("client_id") clientId: String, @Field("refresh_token") refreshToken: String, + @Field("token_type") tokenType: String, + @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, ): Call @GET(ApiConstants.URL_REGISTRATION_FIELDS) diff --git a/auth/src/main/java/org/openedx/auth/data/model/AuthResponse.kt b/auth/src/main/java/org/openedx/auth/data/model/AuthResponse.kt index 6890dcfce..64c5cf27e 100644 --- a/auth/src/main/java/org/openedx/auth/data/model/AuthResponse.kt +++ b/auth/src/main/java/org/openedx/auth/data/model/AuthResponse.kt @@ -1,6 +1,7 @@ package org.openedx.auth.data.model import com.google.gson.annotations.SerializedName +import org.openedx.auth.domain.model.AuthResponse data class AuthResponse( @SerializedName("access_token") @@ -15,5 +16,15 @@ data class AuthResponse( var error: String?, @SerializedName("refresh_token") var refreshToken: String?, -) - +) { + fun mapToDomain(): AuthResponse { + return AuthResponse( + accessToken = accessToken, + tokenType = tokenType, + expiresIn = expiresIn?.times(1000), + scope = scope, + error = error, + refreshToken = refreshToken, + ) + } +} diff --git a/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt b/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt new file mode 100644 index 000000000..5addd621c --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt @@ -0,0 +1,16 @@ +package org.openedx.auth.data.model + +import org.openedx.core.ApiConstants + +/** + * Enum class with types of supported auth types + * + * @param postfix postfix to add to the API call + * @param methodName name of the login type + */ +enum class AuthType(val postfix: String, val methodName: String) { + PASSWORD("", "Password"), + GOOGLE(ApiConstants.AUTH_TYPE_GOOGLE, "Google"), + FACEBOOK(ApiConstants.AUTH_TYPE_FB, "Facebook"), + MICROSOFT(ApiConstants.AUTH_TYPE_MICROSOFT, "Microsoft"), +} diff --git a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt index d0eab71e4..6cf54a7f1 100644 --- a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt +++ b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt @@ -1,13 +1,17 @@ package org.openedx.auth.data.repository import org.openedx.auth.data.api.AuthApi +import org.openedx.auth.data.model.AuthType import org.openedx.auth.data.model.ValidationFields +import org.openedx.auth.domain.model.AuthResponse import org.openedx.core.ApiConstants +import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.RegistrationField import org.openedx.core.system.EdxError class AuthRepository( + private val config: Config, private val api: AuthApi, private val preferencesManager: CorePreferences, ) { @@ -16,19 +20,27 @@ class AuthRepository( username: String, password: String, ) { - val authResponse = api.getAccessToken( + api.getAccessToken( ApiConstants.GRANT_TYPE_PASSWORD, - org.openedx.core.BuildConfig.CLIENT_ID, + config.getOAuthClientId(), username, - password + password, + config.getAccessTokenType(), ) - if (authResponse.error != null) { - throw EdxError.UnknownException(authResponse.error!!) - } - preferencesManager.accessToken = authResponse.accessToken ?: "" - preferencesManager.refreshToken = authResponse.refreshToken ?: "" - val user = api.getProfile().mapToDomain() - preferencesManager.user = user + .mapToDomain() + .processAuthResponse() + } + + suspend fun socialLogin(token: String?, authType: AuthType) { + if (token.isNullOrBlank()) throw IllegalArgumentException("Token is null") + api.exchangeAccessToken( + accessToken = token, + clientId = config.getOAuthClientId(), + tokenType = config.getAccessTokenType(), + authType = authType.postfix + ) + .mapToDomain() + .processAuthResponse() } suspend fun getRegistrationFields(): List { @@ -46,4 +58,15 @@ class AuthRepository( suspend fun passwordReset(email: String): Boolean { return api.passwordReset(email).success } -} \ No newline at end of file + + private suspend fun AuthResponse.processAuthResponse() { + if (error != null) { + throw EdxError.UnknownException(error!!) + } + preferencesManager.accessToken = accessToken ?: "" + preferencesManager.refreshToken = refreshToken ?: "" + preferencesManager.accessTokenExpiresAt = getTokenExpiryTime() + val user = api.getProfile() + preferencesManager.user = user + } +} diff --git a/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt b/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt index 53d68fd22..00fe509af 100644 --- a/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt +++ b/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt @@ -1,5 +1,6 @@ package org.openedx.auth.domain.interactor +import org.openedx.auth.data.model.AuthType import org.openedx.auth.data.model.ValidationFields import org.openedx.auth.data.repository.AuthRepository import org.openedx.core.domain.model.RegistrationField @@ -13,6 +14,10 @@ class AuthInteractor(private val repository: AuthRepository) { repository.login(username, password) } + suspend fun loginSocial(token: String?, authType: AuthType) { + repository.socialLogin(token, authType) + } + suspend fun getRegistrationFields(): List { return repository.getRegistrationFields() } diff --git a/auth/src/main/java/org/openedx/auth/domain/model/AuthResponse.kt b/auth/src/main/java/org/openedx/auth/domain/model/AuthResponse.kt new file mode 100644 index 000000000..47c5a0cf4 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/domain/model/AuthResponse.kt @@ -0,0 +1,19 @@ +package org.openedx.auth.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.openedx.core.utils.TimeUtils + +@Parcelize +data class AuthResponse( + var accessToken: String?, + var tokenType: String?, + var expiresIn: Long?, + var scope: String?, + var error: String?, + var refreshToken: String?, +) : Parcelable { + fun getTokenExpiryTime(): Long { + return (expiresIn ?: 0L) + TimeUtils.getCurrentTime() + } +} diff --git a/auth/src/main/java/org/openedx/auth/domain/model/SocialAuthResponse.kt b/auth/src/main/java/org/openedx/auth/domain/model/SocialAuthResponse.kt new file mode 100644 index 000000000..dae98fd39 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/domain/model/SocialAuthResponse.kt @@ -0,0 +1,10 @@ +package org.openedx.auth.domain.model + +import org.openedx.auth.data.model.AuthType + +data class SocialAuthResponse( + var accessToken: String = "", + var name: String = "", + var email: String = "", + var authType: AuthType = AuthType.PASSWORD, +) 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 1db5b98c1..ff73b7a44 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -4,10 +4,19 @@ import androidx.fragment.app.FragmentManager interface AuthRouter { - fun navigateToMain(fm: FragmentManager) + fun navigateToMain(fm: FragmentManager, courseId: String?) - fun navigateToSignUp(fm: FragmentManager) + fun navigateToSignIn(fm: FragmentManager, courseId: String?) + + fun navigateToLogistration(fm: FragmentManager, courseId: String?) + + fun navigateToSignUp(fm: FragmentManager, courseId: String?) fun navigateToRestorePassword(fm: FragmentManager) -} \ No newline at end of file + fun navigateToWhatsNew(fm: FragmentManager, courseId: String? = null) + + fun navigateToDiscoverCourses(fm: FragmentManager, querySearch: String) + + fun clearBackStack(fm: FragmentManager) +} 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 new file mode 100644 index 000000000..6379b246c --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -0,0 +1,202 @@ +package org.openedx.auth.presentation.logistration + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +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.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import org.koin.android.ext.android.inject +import org.openedx.auth.R +import org.openedx.auth.presentation.AuthRouter +import org.openedx.core.ui.AuthButtonsPanel +import org.openedx.core.ui.SearchBar +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.theme.compose.LogistrationLogoView + +class LogistrationFragment : Fragment() { + + private val router: AuthRouter by inject() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val courseId = arguments?.getString(ARG_COURSE_ID, "") + LogistrationScreen( + onSignInClick = { + router.navigateToSignIn(parentFragmentManager, courseId) + }, + onRegisterClick = { + router.navigateToSignUp(parentFragmentManager, courseId) + }, + onSearchClick = { querySearch -> + router.navigateToDiscoverCourses(parentFragmentManager, querySearch) + } + ) + } + } + } + + companion object { + private const val ARG_COURSE_ID = "courseId" + fun newInstance(courseId: String?): LogistrationFragment { + val fragment = LogistrationFragment() + fragment.arguments = bundleOf( + ARG_COURSE_ID to courseId + ) + return fragment + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun LogistrationScreen( + onSearchClick: (String) -> Unit, + onRegisterClick: () -> Unit, + onSignInClick: () -> Unit, +) { + + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue("")) + } + val scaffoldState = rememberScaffoldState() + val scrollState = rememberScrollState() + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .semantics { + testTagsAsResourceId = true + } + .fillMaxSize() + .navigationBarsPadding(), + backgroundColor = MaterialTheme.appColors.background + ) { + Surface( + modifier = Modifier + .padding(it) + .fillMaxSize() + .verticalScroll(scrollState) + .displayCutoutForLandscape(), + color = MaterialTheme.appColors.background + ) { + Column( + modifier = Modifier.padding( + horizontal = 16.dp, + vertical = 32.dp, + ) + ) { + 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) + ) + val focusManager = LocalFocusManager.current + Column(Modifier.padding(bottom = 8.dp)) { + Text( + modifier = Modifier + .testTag("txt_search_label") + .padding(bottom = 10.dp), + style = MaterialTheme.appTypography.titleMedium, + text = stringResource(id = R.string.pre_auth_search_title), + ) + SearchBar( + modifier = Modifier + .testTag("tf_discovery_search") + .fillMaxWidth() + .height(48.dp), + label = stringResource(id = R.string.pre_auth_search_hint), + requestFocus = false, + searchValue = textFieldValue, + keyboardActions = { + focusManager.clearFocus() + onSearchClick(textFieldValue.text) + }, + onValueChanged = { text -> + textFieldValue = text + }, + onClearValue = { + textFieldValue = TextFieldValue("") + } + ) + } + + Text( + modifier = Modifier + .testTag("txt_explore_all_courses") + .padding(bottom = 32.dp) + .noRippleClickable { + onSearchClick("") + }, + text = stringResource(id = R.string.pre_auth_explore_all_courses), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge + ) + + Spacer(modifier = Modifier.weight(1f)) + + AuthButtonsPanel(onRegisterClick = onRegisterClick, onSignInClick = onSignInClick) + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_9_Night", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun LogistrationPreview() { + OpenEdXTheme { + LogistrationScreen( + onSearchClick = {}, + onSignInClick = {}, + onRegisterClick = {} + ) + } +} 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 014b233d8..dd530bfa0 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 @@ -6,21 +6,46 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* -import androidx.compose.runtime.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi 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.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag 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.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices @@ -28,14 +53,25 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.auth.presentation.ui.LoginTextField +import org.openedx.core.AppUpdateState +import org.openedx.core.R import org.openedx.core.UIMessage -import org.openedx.core.ui.* +import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRequiredScreen +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.ui.windowSizeValue import org.openedx.auth.R as authR class RestorePasswordFragment : Fragment() { @@ -54,22 +90,33 @@ class RestorePasswordFragment : Fragment() { val uiState by viewModel.uiState.observeAsState(RestorePasswordUIState.Initial) val uiMessage by viewModel.uiMessage.observeAsState() - RestorePasswordScreen( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - onBackClick = { - requireActivity().supportFragmentManager.popBackStackImmediate() - }, - onRestoreButtonClick = { - viewModel.passwordReset(it) - } - ) + val appUpgradeEvent by viewModel.appUpgradeEventUIState.observeAsState(null) + + if (appUpgradeEvent == null) { + RestorePasswordScreen( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onBackClick = { + requireActivity().supportFragmentManager.popBackStackImmediate() + }, + onRestoreButtonClick = { + viewModel.passwordReset(it) + } + ) + } else { + AppUpgradeRequiredScreen( + onUpdateClick = { + AppUpdateState.openPlayMarket(requireContext()) + } + ) + } } } } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun RestorePasswordScreen( windowSize: WindowSize, @@ -87,6 +134,9 @@ private fun RestorePasswordScreen( Scaffold( scaffoldState = scaffoldState, modifier = Modifier + .semantics { + testTagsAsResourceId = true + } .fillMaxSize() .navigationBarsPadding(), backgroundColor = MaterialTheme.appColors.background @@ -158,6 +208,7 @@ private fun RestorePasswordScreen( ) { Text( modifier = Modifier + .testTag("txt_screen_title") .fillMaxWidth(), text = stringResource(id = authR.string.auth_forgot_your_password), color = Color.White, @@ -174,8 +225,7 @@ private fun RestorePasswordScreen( } Surface( - modifier = Modifier - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), color = MaterialTheme.appColors.background, shape = MaterialTheme.appShapes.screenBackgroundShape ) { @@ -191,11 +241,13 @@ private fun RestorePasswordScreen( Column( Modifier .then(contentPaddings) + .displayCutoutForLandscape() .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Text( modifier = Modifier + .testTag("txt_forgot_password_title") .fillMaxWidth(), text = stringResource(id = authR.string.auth_forgot_your_password), style = MaterialTheme.appTypography.displaySmall, @@ -204,6 +256,7 @@ private fun RestorePasswordScreen( Spacer(Modifier.height(2.dp)) Text( modifier = Modifier + .testTag("txt_forgot_password_description") .fillMaxWidth(), text = stringResource(id = authR.string.auth_please_enter_your_log_in), style = MaterialTheme.appTypography.titleSmall, @@ -212,6 +265,8 @@ private fun RestorePasswordScreen( Spacer(modifier = Modifier.height(32.dp)) LoginTextField( modifier = Modifier.fillMaxWidth(), + title = stringResource(id = authR.string.auth_email), + description = stringResource(id = authR.string.auth_example_email), onValueChanged = { email = it }, @@ -234,7 +289,7 @@ private fun RestorePasswordScreen( } } else { OpenEdXButton( - width = buttonWidth, + width = buttonWidth.testTag("btn_reset_password"), text = stringResource(id = authR.string.auth_reset_password), onClick = { onRestoreButtonClick(email) @@ -243,10 +298,12 @@ private fun RestorePasswordScreen( } } } + is RestorePasswordUIState.Success -> { Column( Modifier .then(contentPaddings) + .displayCutoutForLandscape() .fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally @@ -281,7 +338,7 @@ private fun RestorePasswordScreen( Spacer(Modifier.height(48.dp)) OpenEdXButton( width = buttonWidth, - text = stringResource(id = authR.string.auth_sign_in), + text = stringResource(id = R.string.core_sign_in), onClick = { onBackClick() } 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 e8d635e03..427f2f263 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 @@ -3,6 +3,7 @@ package org.openedx.auth.presentation.restore import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics import org.openedx.core.BaseViewModel @@ -13,12 +14,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 kotlinx.coroutines.launch +import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.AppUpgradeNotifier class RestorePasswordViewModel( private val interactor: AuthInteractor, private val resourceManager: ResourceManager, - private val analytics: AuthAnalytics + private val analytics: AuthAnalytics, + private val appUpgradeNotifier: AppUpgradeNotifier ) : BaseViewModel() { private val _uiState = MutableLiveData() @@ -29,6 +32,14 @@ class RestorePasswordViewModel( val uiMessage: LiveData get() = _uiMessage + private val _appUpgradeEvent = MutableLiveData() + val appUpgradeEventUIState: LiveData + get() = _appUpgradeEvent + + init { + collectAppUpgradeEvent() + } + fun passwordReset(email: String) { _uiState.value = RestorePasswordUIState.Loading viewModelScope.launch { @@ -64,4 +75,13 @@ class RestorePasswordViewModel( } } } + + private fun collectAppUpgradeEvent() { + viewModelScope.launch { + appUpgradeNotifier.notifier.collect { event -> + _appUpgradeEvent.value = event + } + } + } + } \ No newline at end of file diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt index 9ea354c7f..1adcaa3a1 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt @@ -1,54 +1,35 @@ package org.openedx.auth.presentation.signin -import android.content.res.Configuration.UI_MODE_NIGHT_NO -import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* -import androidx.compose.runtime.* +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -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.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import org.openedx.auth.R -import org.openedx.auth.presentation.AuthRouter -import org.openedx.auth.presentation.ui.LoginTextField -import org.openedx.core.UIMessage -import org.openedx.core.ui.* -import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appShapes -import org.openedx.core.ui.theme.appTypography import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.openedx.auth.data.model.AuthType +import org.openedx.auth.presentation.AuthRouter +import org.openedx.auth.presentation.signin.compose.LoginScreen +import org.openedx.core.AppUpdateState +import org.openedx.core.presentation.global.WhatsNewGlobalManager +import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRequiredScreen +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.theme.OpenEdXTheme class SignInFragment : Fragment() { - private val viewModel: SignInViewModel by viewModel() + private val viewModel: SignInViewModel by viewModel { + parametersOf(requireArguments().getString(ARG_COURSE_ID, null)) + } private val router: AuthRouter by inject() + private val whatsNewGlobalManager by inject() override fun onCreateView( inflater: LayoutInflater, @@ -59,310 +40,79 @@ class SignInFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() - - val showProgress by viewModel.showProgress.observeAsState(initial = false) + val state by viewModel.uiState.collectAsState() val uiMessage by viewModel.uiMessage.observeAsState() - val loginSuccess by viewModel.loginSuccess.observeAsState(initial = false) + val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null) + + if (appUpgradeEvent == null) { + LoginScreen( + windowSize = windowSize, + state = state, + uiMessage = uiMessage, + onEvent = { event -> + when (event) { + is AuthEvent.SignIn -> viewModel.login(event.login, event.password) + is AuthEvent.SocialSignIn -> viewModel.socialAuth( + this@SignInFragment, + event.authType + ) + + AuthEvent.ForgotPasswordClick -> { + viewModel.forgotPasswordClickedEvent() + router.navigateToRestorePassword(parentFragmentManager) + } + + AuthEvent.RegisterClick -> { + viewModel.signUpClickedEvent() + router.navigateToSignUp(parentFragmentManager, null) + } + + AuthEvent.BackClick -> { + requireActivity().supportFragmentManager.popBackStackImmediate() + } + } + }, + ) + LaunchedEffect(state.loginSuccess) { + val isNeedToShowWhatsNew = + whatsNewGlobalManager.shouldShowWhatsNew() + if (state.loginSuccess) { + router.clearBackStack(parentFragmentManager) + if (isNeedToShowWhatsNew) { + router.navigateToWhatsNew(parentFragmentManager, viewModel.courseId) + } else { + router.navigateToMain(parentFragmentManager, viewModel.courseId) + } + } - LoginScreen( - windowSize = windowSize, - showProgress = showProgress, - uiMessage = uiMessage, - onLoginClick = { login, password -> - viewModel.login(login, password) - }, - onRegisterClick = { - viewModel.signUpClickedEvent() - router.navigateToSignUp(parentFragmentManager) - }, - onForgotPasswordClick = { - viewModel.forgotPasswordClickedEvent() - router.navigateToRestorePassword(parentFragmentManager) - } - ) - - LaunchedEffect(loginSuccess) { - if (loginSuccess) { - router.navigateToMain(parentFragmentManager) } + } else { + AppUpgradeRequiredScreen( + onUpdateClick = { + AppUpdateState.openPlayMarket(requireContext()) + } + ) } } } } -} -@Composable -private fun LoginScreen( - windowSize: WindowSize, - showProgress: Boolean, - uiMessage: UIMessage?, - onLoginClick: (login: String, password: String) -> Unit, - onRegisterClick: () -> Unit, - onForgotPasswordClick: () -> Unit -) { - val scaffoldState = rememberScaffoldState() - val scrollState = rememberScrollState() - - Scaffold( - scaffoldState = scaffoldState, - modifier = Modifier - .fillMaxSize() - .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background - ) { - - val contentPaddings by remember { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier - .widthIn(Dp.Unspecified, 420.dp) - .padding( - top = 32.dp, - bottom = 40.dp - ), - compact = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 28.dp) - ) + companion object { + private const val ARG_COURSE_ID = "courseId" + fun newInstance(courseId: String?): SignInFragment { + val fragment = SignInFragment() + fragment.arguments = bundleOf( + ARG_COURSE_ID to courseId ) - } - val buttonWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(232.dp, Dp.Unspecified), - compact = Modifier.fillMaxWidth() - ) - ) - } - - Image( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.3f), - painter = painterResource(id = org.openedx.core.R.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null - ) - HandleUIMessage( - uiMessage = uiMessage, - scaffoldState = scaffoldState - ) - - Column( - Modifier.padding(it), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.2f), - contentAlignment = Alignment.Center - ) { - Image( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_logo), - contentDescription = null, - modifier = Modifier - .padding(top = 20.dp) - .width(170.dp) - .height(48.dp) - ) - } - Surface( - color = MaterialTheme.appColors.background, - shape = MaterialTheme.appShapes.screenBackgroundShape, - modifier = Modifier - .fillMaxSize() - ) { - Box(contentAlignment = Alignment.TopCenter) { - Column( - modifier = Modifier - .background(MaterialTheme.appColors.background) - .verticalScroll(scrollState) - .then(contentPaddings), - ) { - Text( - text = stringResource(id = R.string.auth_sign_in), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.displaySmall - ) - Text( - modifier = Modifier.padding(top = 4.dp), - text = stringResource(id = R.string.auth_welcome_back), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleSmall - ) - Spacer(modifier = Modifier.height(24.dp)) - AuthForm( - buttonWidth, - showProgress, - onLoginClick, - onRegisterClick, - onForgotPasswordClick - ) - } - } - } + return fragment } } } -@Composable -private fun AuthForm( - buttonWidth: Modifier, - isLoading: Boolean = false, - onLoginClick: (login: String, password: String) -> Unit, - onRegisterClick: () -> Unit, - onForgotPasswordClick: () -> Unit -) { - var login by rememberSaveable { mutableStateOf("") } - var password by rememberSaveable { mutableStateOf("") } - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - LoginTextField( - modifier = Modifier - .fillMaxWidth(), - onValueChanged = { - login = it - }) - - Spacer(modifier = Modifier.height(18.dp)) - PasswordTextField( - modifier = Modifier - .fillMaxWidth(), - onValueChanged = { - password = it - }, - onPressDone = { - onLoginClick(login, password) - } - ) - - Row( - Modifier - .fillMaxWidth() - .padding(top = 20.dp, bottom = 36.dp) - ) { - Text( - modifier = Modifier.noRippleClickable { - onRegisterClick() - }, - text = stringResource(id = R.string.auth_register), - color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - modifier = Modifier.noRippleClickable { - onForgotPasswordClick() - }, - text = stringResource(id = R.string.auth_forgot_password), - color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge - ) - } - - if (isLoading) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } else { - OpenEdXButton( - width = buttonWidth, - text = stringResource(id = R.string.auth_sign_in), - onClick = { - onLoginClick(login, password) - } - ) - } - } -} - - -@Composable -private fun PasswordTextField( - modifier: Modifier = Modifier, - onValueChanged: (String) -> Unit, - onPressDone: () -> Unit, -) { - var passwordTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf( - TextFieldValue("") - ) - } - val focusManager = LocalFocusManager.current - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = org.openedx.core.R.string.core_password), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.labelLarge - ) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - modifier = modifier, - value = passwordTextFieldValue, - onValueChange = { - passwordTextFieldValue = it - onValueChanged(it.text.trim()) - }, - colors = TextFieldDefaults.outlinedTextFieldColors( - unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground - ), - shape = MaterialTheme.appShapes.textFieldShape, - placeholder = { - Text( - text = stringResource(id = R.string.auth_enter_password), - color = MaterialTheme.appColors.textFieldHint, - style = MaterialTheme.appTypography.bodyMedium - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - visualTransformation = PasswordVisualTransformation(), - keyboardActions = KeyboardActions { - focusManager.clearFocus() - onPressDone() - }, - textStyle = MaterialTheme.appTypography.bodyMedium, - singleLine = true - ) +internal sealed interface AuthEvent { + data class SignIn(val login: String, val password: String) : AuthEvent + data class SocialSignIn(val authType: AuthType) : AuthEvent + object RegisterClick : AuthEvent + object ForgotPasswordClick : AuthEvent + object BackClick : AuthEvent } - -@Preview(uiMode = UI_MODE_NIGHT_NO) -@Preview(uiMode = UI_MODE_NIGHT_YES) -@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_NO) -@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun SignInScreenPreview() { - OpenEdXTheme { - LoginScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - showProgress = false, - uiMessage = null, - onLoginClick = { _, _ -> - - }, - onRegisterClick = {}, - onForgotPasswordClick = {} - ) - } -} - - -@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_NO) -@Preview(name = "NEXUS_9_Night", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun SignInScreenTabletPreview() { - OpenEdXTheme { - LoginScreen( - windowSize = WindowSize(WindowType.Expanded, WindowType.Expanded), - showProgress = false, - uiMessage = null, - onLoginClick = { _, _ -> - - }, - onRegisterClick = {}, - onForgotPasswordClick = {} - ) - } -} \ No newline at end of file diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt new file mode 100644 index 000000000..829d376f1 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt @@ -0,0 +1,21 @@ +package org.openedx.auth.presentation.signin + +/** + * Data class to store UI state of the SignIn screen + * + * @param isFacebookAuthEnabled is Facebook auth enabled + * @param isGoogleAuthEnabled is Google auth enabled + * @param isMicrosoftAuthEnabled is Microsoft auth enabled + * @param isSocialAuthEnabled is OAuth buttons visible + * @param showProgress is progress visible + * @param loginSuccess is login succeed + */ +internal data class SignInUIState( + val isFacebookAuthEnabled: Boolean = false, + val isGoogleAuthEnabled: Boolean = false, + val isMicrosoftAuthEnabled: Boolean = false, + val isSocialAuthEnabled: Boolean = false, + val isLogistrationEnabled: Boolean = false, + val showProgress: Boolean = false, + val loginSuccess: Boolean = false, +) 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 d9ca3bed5..e5532429b 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 @@ -1,20 +1,33 @@ package org.openedx.auth.presentation.signin +import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.openedx.auth.R +import org.openedx.auth.data.model.AuthType import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.BaseViewModel import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.Validator +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.extension.isInternetError import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager -import kotlinx.coroutines.launch -import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.utils.Logger import org.openedx.core.R as CoreRes class SignInViewModel( @@ -22,25 +35,42 @@ class SignInViewModel( private val resourceManager: ResourceManager, private val preferencesManager: CorePreferences, private val validator: Validator, - private val analytics: AuthAnalytics + private val appUpgradeNotifier: AppUpgradeNotifier, + private val analytics: AuthAnalytics, + private val oAuthHelper: OAuthHelper, + config: Config, + val courseId: String?, ) : BaseViewModel() { - private val _showProgress = MutableLiveData() - val showProgress: LiveData - get() = _showProgress + private val logger = Logger("SignInViewModel") + + private val _uiState = MutableStateFlow( + SignInUIState( + isFacebookAuthEnabled = config.getFacebookConfig().isEnabled(), + isGoogleAuthEnabled = config.getGoogleConfig().isEnabled(), + isMicrosoftAuthEnabled = config.getMicrosoftConfig().isEnabled(), + isSocialAuthEnabled = config.isSocialAuthEnabled(), + isLogistrationEnabled = config.isPreLoginExperienceEnabled(), + ) + ) + internal val uiState: StateFlow = _uiState private val _uiMessage = SingleEventLiveData() val uiMessage: LiveData get() = _uiMessage - private val _loginSuccess = SingleEventLiveData() - val loginSuccess: LiveData - get() = _loginSuccess + private val _appUpgradeEvent = MutableLiveData() + val appUpgradeEvent: LiveData + get() = _appUpgradeEvent + + init { + collectAppUpgradeEvent() + } fun login(username: String, password: String) { - if (!validator.isEmailValid(username)) { + if (!validator.isEmailOrUserNameValid(username)) { _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.auth_invalid_email)) + UIMessage.SnackBarMessage(resourceManager.getString(R.string.auth_invalid_email_username)) return } if (!validator.isPasswordValid(password)) { @@ -49,13 +79,13 @@ class SignInViewModel( return } - _showProgress.value = true + _uiState.update { it.copy(showProgress = true) } viewModelScope.launch { try { interactor.login(username, password) - _loginSuccess.value = true + _uiState.update { it.copy(loginSuccess = true) } setUserId() - analytics.userLoginEvent(LoginMethod.PASSWORD.methodName) + analytics.userLoginEvent(AuthType.PASSWORD.methodName) } catch (e: Exception) { if (e is EdxError.InvalidGrantException) { _uiMessage.value = @@ -68,7 +98,28 @@ class SignInViewModel( UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_unknown_error)) } } - _showProgress.value = false + _uiState.update { it.copy(showProgress = false) } + } + } + + private fun collectAppUpgradeEvent() { + viewModelScope.launch { + appUpgradeNotifier.notifier.collect { event -> + _appUpgradeEvent.value = event + } + } + } + + fun socialAuth(fragment: Fragment, authType: AuthType) { + _uiState.update { it.copy(showProgress = true) } + viewModelScope.launch { + withContext(Dispatchers.IO) { + runCatching { + oAuthHelper.socialAuth(fragment, authType) + } + } + .getOrNull() + .checkToken() } } @@ -80,17 +131,49 @@ class SignInViewModel( analytics.forgotPasswordClickedEvent() } + override fun onCleared() { + super.onCleared() + oAuthHelper.clear() + } + + private suspend fun exchangeToken(token: String, authType: AuthType) { + runCatching { + interactor.loginSocial(token, authType) + }.onFailure { error -> + logger.e { "Social login error: $error" } + onUnknownError() + }.onSuccess { + logger.d { "Social login (${authType.methodName}) success" } + _uiState.update { it.copy(loginSuccess = true) } + setUserId() + analytics.userLoginEvent(authType.methodName) + _uiState.update { it.copy(showProgress = false) } + } + } + + private fun onUnknownError(message: (() -> String)? = null) { + message?.let { + logger.e { it() } + } + _uiMessage.value = UIMessage.SnackBarMessage( + resourceManager.getString(CoreRes.string.core_error_unknown_error) + ) + _uiState.update { it.copy(showProgress = false) } + } + private fun setUserId() { preferencesManager.user?.let { analytics.setUserIdForSession(it.id) } } -} -private enum class LoginMethod(val methodName: String) { - PASSWORD("Password"), - FACEBOOK("Facebook"), - GOOGLE("Google"), - MICROSOFT("Microsoft") + private suspend fun SocialAuthResponse?.checkToken() { + this?.accessToken?.let { token -> + if (token.isNotEmpty()) { + exchangeToken(token, authType) + } else { + _uiState.update { it.copy(showProgress = false) } + } + } ?: onUnknownError() + } } - 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 new file mode 100644 index 000000000..0abcf0bf9 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -0,0 +1,370 @@ +package org.openedx.auth.presentation.signin.compose + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +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.testTag +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.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.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +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.SocialAuthView +import org.openedx.core.UIMessage +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.theme.compose.SignInLogoView +import org.openedx.core.ui.windowSizeValue +import org.openedx.core.R as coreR + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun LoginScreen( + windowSize: WindowSize, + state: SignInUIState, + uiMessage: UIMessage?, + onEvent: (AuthEvent) -> Unit, +) { + val scaffoldState = rememberScaffoldState() + val scrollState = rememberScrollState() + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .semantics { + testTagsAsResourceId = true + } + .fillMaxSize() + .navigationBarsPadding(), + backgroundColor = MaterialTheme.appColors.background + ) { + val contentPaddings by remember { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier + .widthIn(Dp.Unspecified, 420.dp) + .padding( + top = 32.dp, + bottom = 40.dp + ), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp) + ) + ) + } + val buttonWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(232.dp, Dp.Unspecified), + compact = Modifier.fillMaxWidth() + ) + ) + } + + Image( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.3f), + painter = painterResource(id = coreR.drawable.core_top_header), + contentScale = ContentScale.FillBounds, + contentDescription = null + ) + HandleUIMessage( + uiMessage = uiMessage, + scaffoldState = scaffoldState + ) + if (state.isLogistrationEnabled) { + Box( + modifier = Modifier + .statusBarsPadding() + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + BackBtn( + modifier = Modifier.padding(end = 16.dp), + tint = Color.White + ) { + onEvent(AuthEvent.BackClick) + } + } + } + Column( + Modifier.padding(it), + horizontalAlignment = Alignment.CenterHorizontally + ) { + SignInLogoView() + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape, + modifier = Modifier + .fillMaxSize() + ) { + Box(contentAlignment = Alignment.TopCenter) { + Column( + modifier = Modifier + .background(MaterialTheme.appColors.background) + .verticalScroll(scrollState) + .displayCutoutForLandscape() + .then(contentPaddings), + ) { + Text( + modifier = Modifier.testTag("txt_sign_in_title"), + text = stringResource(id = coreR.string.core_sign_in), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.displaySmall + ) + Text( + modifier = Modifier + .testTag("txt_sign_in_description") + .padding(top = 4.dp), + text = stringResource(id = R.string.auth_welcome_back), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleSmall + ) + Spacer(modifier = Modifier.height(24.dp)) + AuthForm( + buttonWidth, + state, + onEvent, + ) + } + } + } + } + } +} + +@Composable +private fun AuthForm( + buttonWidth: Modifier, + state: SignInUIState, + onEvent: (AuthEvent) -> Unit, +) { + var login by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + LoginTextField( + modifier = Modifier + .fillMaxWidth(), + title = stringResource(id = R.string.auth_email_username), + description = stringResource(id = R.string.auth_enter_email_username), + onValueChanged = { + login = it + }) + + Spacer(modifier = Modifier.height(18.dp)) + PasswordTextField( + modifier = Modifier + .fillMaxWidth(), + onValueChanged = { + password = it + }, + onPressDone = { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } + ) + + Row( + Modifier + .fillMaxWidth() + .padding(top = 20.dp, bottom = 36.dp) + ) { + if (state.isLogistrationEnabled.not()) { + Text( + modifier = Modifier + .testTag("txt_register") + .noRippleClickable { + onEvent(AuthEvent.RegisterClick) + }, + text = stringResource(id = coreR.string.core_register), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier + .testTag("txt_forgot_password") + .noRippleClickable { + onEvent(AuthEvent.ForgotPasswordClick) + }, + text = stringResource(id = R.string.auth_forgot_password), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge + ) + } + + if (state.showProgress) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } else { + OpenEdXButton( + width = buttonWidth.testTag("btn_sign_in"), + text = stringResource(id = coreR.string.core_sign_in), + onClick = { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } + ) + } + if (state.isSocialAuthEnabled) { + SocialAuthView( + modifier = buttonWidth, + isGoogleAuthEnabled = state.isGoogleAuthEnabled, + isFacebookAuthEnabled = state.isFacebookAuthEnabled, + isMicrosoftAuthEnabled = state.isMicrosoftAuthEnabled, + isSignIn = true, + ) { + onEvent(AuthEvent.SocialSignIn(it)) + } + } + } +} + +@Composable +private fun PasswordTextField( + modifier: Modifier = Modifier, + onValueChanged: (String) -> Unit, + onPressDone: () -> Unit, +) { + var passwordTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue("") + ) + } + val focusManager = LocalFocusManager.current + Text( + modifier = Modifier + .testTag("txt_password_label") + .fillMaxWidth(), + text = stringResource(id = coreR.string.core_password), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + modifier = modifier.testTag("tf_password"), + value = passwordTextFieldValue, + onValueChange = { + passwordTextFieldValue = it + onValueChanged(it.text.trim()) + }, + colors = TextFieldDefaults.outlinedTextFieldColors( + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + backgroundColor = MaterialTheme.appColors.textFieldBackground + ), + shape = MaterialTheme.appShapes.textFieldShape, + placeholder = { + Text( + modifier = Modifier.testTag("txt_password_placeholder"), + text = stringResource(id = R.string.auth_enter_password), + color = MaterialTheme.appColors.textFieldHint, + style = MaterialTheme.appTypography.bodyMedium + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + visualTransformation = PasswordVisualTransformation(), + keyboardActions = KeyboardActions { + focusManager.clearFocus() + onPressDone() + }, + textStyle = MaterialTheme.appTypography.bodyMedium, + singleLine = true + ) +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun SignInScreenPreview() { + OpenEdXTheme { + LoginScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + state = SignInUIState(), + uiMessage = null, + onEvent = {}, + ) + } +} + + +@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_9_Night", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun SignInScreenTabletPreview() { + OpenEdXTheme { + LoginScreen( + windowSize = WindowSize(WindowType.Expanded, WindowType.Expanded), + state = SignInUIState().copy( + isSocialAuthEnabled = true, + isFacebookAuthEnabled = true, + isGoogleAuthEnabled = true, + isMicrosoftAuthEnabled = true, + ), + uiMessage = null, + onEvent = {}, + ) + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt index 59a25fca0..97bfe45d9 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt @@ -1,57 +1,31 @@ -@file:OptIn(ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class) - package org.openedx.auth.presentation.signup -import android.content.res.Configuration -import android.content.res.Configuration.ORIENTATION_PORTRAIT import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.* -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import org.openedx.auth.presentation.AuthRouter -import org.openedx.auth.presentation.ui.ExpandableText -import org.openedx.auth.presentation.ui.OptionalFields -import org.openedx.auth.presentation.ui.RequiredFields -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.domain.model.RegistrationFieldType -import org.openedx.core.ui.* -import org.openedx.core.ui.theme.* -import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.openedx.auth.data.model.AuthType +import org.openedx.auth.presentation.AuthRouter +import org.openedx.auth.presentation.signup.compose.SignUpView +import org.openedx.core.AppUpdateState +import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRequiredScreen +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.theme.OpenEdXTheme class SignUpFragment : Fragment() { - private val viewModel by viewModel() + private val viewModel by viewModel { + parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + } private val router by inject() override fun onCreate(savedInstanceState: Bundle?) { @@ -69,413 +43,58 @@ class SignUpFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() - val uiState by viewModel.uiState.observeAsState() - val uiMessage by viewModel.uiMessage.observeAsState() - val isButtonClicked by viewModel.isButtonLoading.observeAsState(false) - val successLogin by viewModel.successLogin.observeAsState() - val validationError by viewModel.validationError.observeAsState(false) - - RegistrationScreen( - windowSize = windowSize, - uiState = uiState!!, - uiMessage = uiMessage, - isButtonClicked = isButtonClicked, - validationError, - onBackClick = { - requireActivity().supportFragmentManager.popBackStackImmediate() - }, - onRegisterClick = { map -> - viewModel.register(map.mapValues { it.value ?: "" }) - } - ) - - LaunchedEffect(successLogin) { - if (successLogin == true) { - router.navigateToMain(parentFragmentManager) - } - } - } - } - } -} - -@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) -@Composable -internal fun RegistrationScreen( - windowSize: WindowSize, - uiState: SignUpUIState, - uiMessage: UIMessage?, - isButtonClicked: Boolean, - validationError: Boolean, - onBackClick: () -> Unit, - onRegisterClick: (Map) -> Unit -) { - val scaffoldState = rememberScaffoldState() - val configuration = LocalConfiguration.current - val focusManager = LocalFocusManager.current - val bottomSheetScaffoldState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - skipHalfExpanded = true - ) - val coroutine = rememberCoroutineScope() - val keyboardController = LocalSoftwareKeyboardController.current - var expandedList by rememberSaveable { - mutableStateOf(emptyList()) - } - val selectableNamesMap = rememberSaveableMap { - mutableStateMapOf() - } - val serverFieldName = rememberSaveable { - mutableStateOf("") - } - var showOptionalFields by rememberSaveable { - mutableStateOf(false) - } - val mapFields = rememberSaveableMap { - mutableStateMapOf() - } - val showErrorMap = rememberSaveableMap { - mutableStateMapOf() - } - val scrollState = rememberScrollState() - - val haptic = LocalHapticFeedback.current - - val listState = rememberLazyListState() - - var bottomDialogTitle by rememberSaveable { - mutableStateOf("") - } - - var searchValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue()) - } - - val isImeVisible by isImeVisibleState() - - LaunchedEffect(validationError) { - if (validationError) { - coroutine.launch { - scrollState.animateScrollTo(0, tween(300)) - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } - } - } - - LaunchedEffect(bottomSheetScaffoldState.isVisible) { - if (!bottomSheetScaffoldState.isVisible) { - focusManager.clearFocus() - searchValue = TextFieldValue("") - } - } - - Scaffold( - scaffoldState = scaffoldState, - modifier = Modifier - .fillMaxSize() - .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background - ) { - - val topBarPadding by remember { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier - .width(560.dp) - .padding(bottom = 24.dp), - compact = Modifier - .fillMaxWidth() - .padding(bottom = 6.dp) - ) - ) - } - val contentPaddings by remember { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier - .widthIn(Dp.Unspecified, 420.dp) - .padding( - top = 32.dp, - bottom = 40.dp - ), - compact = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 28.dp) - ) - ) - } - val buttonWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(232.dp, Dp.Unspecified), - compact = Modifier.fillMaxWidth() - ) - ) - } - - ModalBottomSheetLayout( - modifier = Modifier - .padding(bottom = if (isImeVisible && bottomSheetScaffoldState.isVisible) 120.dp else 0.dp) - .noRippleClickable { - if (bottomSheetScaffoldState.isVisible) { - coroutine.launch { - bottomSheetScaffoldState.hide() - } - } - }, - sheetState = bottomSheetScaffoldState, - sheetShape = MaterialTheme.appShapes.screenBackgroundShape, - scrimColor = Color.Black.copy(alpha = 0.4f), - sheetBackgroundColor = MaterialTheme.appColors.background, - sheetContent = { - SheetContent( - title = bottomDialogTitle, - searchValue = searchValue, - expandedList = expandedList, - listState = listState, - onItemClick = { item -> - mapFields[serverFieldName.value] = item.value - selectableNamesMap[serverFieldName.value] = item.name - coroutine.launch { - bottomSheetScaffoldState.hide() + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) + + if (uiState.appUpgradeEvent == null) { + SignUpView( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onBackClick = { + requireActivity().supportFragmentManager.popBackStackImmediate() + }, + onRegisterClick = { authType -> + when (authType) { + AuthType.PASSWORD -> viewModel.register() + AuthType.GOOGLE, + AuthType.FACEBOOK, + AuthType.MICROSOFT -> viewModel.socialAuth( + this@SignUpFragment, + authType + ) + } + }, + onFieldUpdated = { key, value -> + viewModel.updateField(key, value) } - }, - searchValueChanged = { - searchValue = TextFieldValue(it) - } - ) - } - ) { - Image( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.3f), - painter = painterResource(id = R.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null - ) - HandleUIMessage( - uiMessage = uiMessage, - scaffoldState = scaffoldState - ) - Column( - Modifier - .fillMaxWidth() - .padding(it) - .statusBarsInset(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .then(topBarPadding), - contentAlignment = Alignment.CenterStart - ) { - Text( - modifier = Modifier - .fillMaxWidth(), - text = stringResource(id = org.openedx.auth.R.string.auth_register), - color = Color.White, - textAlign = TextAlign.Center, - style = MaterialTheme.appTypography.titleMedium ) - BackBtn( - modifier = Modifier.padding(end = 16.dp), - tint = Color.White - ) { - onBackClick() - } - } - Surface( - color = MaterialTheme.appColors.background, - shape = MaterialTheme.appShapes.screenBackgroundShape, - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxHeight() - .background(MaterialTheme.appColors.background), - verticalArrangement = Arrangement.spacedBy(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - when (uiState) { - is SignUpUIState.Loading -> { - Box( - Modifier - .fillMaxSize(), contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } - is SignUpUIState.Fields -> { - mapFields.let { - if (it.isEmpty()) { - it.putAll(uiState.fields.associate { it.name to "" }) - it["honor_code"] = true.toString() - } - } - Column( - Modifier - .fillMaxHeight() - .verticalScroll(scrollState) - .then(contentPaddings), - verticalArrangement = Arrangement.spacedBy(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Column() { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = org.openedx.auth.R.string.auth_sign_up), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.displaySmall - ) - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp), - text = stringResource(id = org.openedx.auth.R.string.auth_create_new_account), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleSmall - ) - } - RequiredFields( - fields = uiState.fields, - mapFields = mapFields, - showErrorMap = showErrorMap, - selectableNamesMap = selectableNamesMap, - onSelectClick = { serverName, field, list -> - keyboardController?.hide() - serverFieldName.value = serverName - expandedList = list - coroutine.launch { - if (bottomSheetScaffoldState.isVisible) { - bottomSheetScaffoldState.hide() - } else { - bottomDialogTitle = field.label - showErrorMap[field.name] = false - bottomSheetScaffoldState.show() - } - } - } - ) - if (uiState.optionalFields.isNotEmpty()) { - ExpandableText(isExpanded = showOptionalFields, onClick = { - showOptionalFields = !showOptionalFields - }) - Surface(color = MaterialTheme.appColors.background) { - AnimatedVisibility(visible = showOptionalFields) { - OptionalFields( - fields = uiState.optionalFields, - mapFields = mapFields, - showErrorMap = showErrorMap, - selectableNamesMap = selectableNamesMap, - onSelectClick = { serverName, field, list -> - keyboardController?.hide() - serverFieldName.value = - serverName - expandedList = list - coroutine.launch { - if (bottomSheetScaffoldState.isVisible) { - bottomSheetScaffoldState.hide() - } else { - bottomDialogTitle = field.label - showErrorMap[field.name] = false - bottomSheetScaffoldState.show() - } - } - } - ) - } - } - } - if (isButtonClicked) { - Box( - Modifier - .fillMaxWidth() - .height(42.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } else { - OpenEdXButton( - width = buttonWidth, - text = stringResource(id = org.openedx.auth.R.string.auth_create_account), - onClick = { - showErrorMap.clear() - onRegisterClick(mapFields.toMap()) - } - ) - } - Spacer(Modifier.height(70.dp)) - } - } + LaunchedEffect(uiState.successLogin) { + if (uiState.successLogin) { + router.clearBackStack(requireActivity().supportFragmentManager) + router.navigateToMain(parentFragmentManager, viewModel.courseId) } } + } else { + AppUpgradeRequiredScreen( + onUpdateClick = { + AppUpdateState.openPlayMarket(requireContext()) + } + ) } } } } -} - - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun RegistrationScreenPreview() { - OpenEdXTheme { - RegistrationScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = SignUpUIState.Fields( - fields = listOf(field, field, field), - optionalFields = listOf( - field - ) - ), - uiMessage = null, - isButtonClicked = false, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } -} -@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun RegistrationScreenTabletPreview() { - OpenEdXTheme { - RegistrationScreen( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = SignUpUIState.Fields( - fields = listOf(field, field, field), - optionalFields = listOf( - field - ) - ), - uiMessage = null, - isButtonClicked = false, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) + companion object { + private const val ARG_COURSE_ID = "courseId" + fun newInstance(courseId: String?): SignUpFragment { + val fragment = SignUpFragment() + fragment.arguments = bundleOf( + ARG_COURSE_ID to courseId + ) + return fragment + } } } - -private val option = RegistrationField.Option("def", "Bachelor", "Android") - -private val field = RegistrationField( - "Fullname", - "Fullname", - RegistrationFieldType.TEXT, - "Fullname", - instructions = "Enter your fullname", - exposed = false, - required = true, - restrictions = RegistrationField.Restrictions(), - options = listOf(option, option), - errorInstructions = "" -) \ No newline at end of file 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 a6af275f5..23e0458d9 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 @@ -1,11 +1,19 @@ 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 -sealed class SignUpUIState { - data class Fields( - val fields: List, - val optionalFields: List - ) : SignUpUIState() - object Loading : SignUpUIState() -} \ No newline at end of file +data class SignUpUIState( + val allFields: List = emptyList(), + val isFacebookAuthEnabled: Boolean = false, + val isGoogleAuthEnabled: Boolean = false, + val isMicrosoftAuthEnabled: Boolean = false, + val isSocialAuthEnabled: Boolean = false, + val isLoading: Boolean = false, + val isButtonLoading: Boolean = false, + val validationError: Boolean = false, + val successLogin: Boolean = false, + val socialAuth: SocialAuthResponse? = null, + val appUpgradeEvent: AppUpgradeEvent? = null, +) 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 4ec76f151..af1b8e094 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 @@ -1,117 +1,204 @@ package org.openedx.auth.presentation.signup -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.fragment.app.Fragment import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.openedx.auth.data.model.AuthType import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage +import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.RegistrationField +import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager -import kotlinx.coroutines.launch +import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.utils.Logger class SignUpViewModel( private val interactor: AuthInteractor, private val resourceManager: ResourceManager, private val analytics: AuthAnalytics, - private val preferencesManager: CorePreferences + private val preferencesManager: CorePreferences, + private val appUpgradeNotifier: AppUpgradeNotifier, + private val oAuthHelper: OAuthHelper, + private val config: Config, + val courseId: String?, ) : BaseViewModel() { - private val _uiState = MutableLiveData(SignUpUIState.Loading) - val uiState: LiveData - get() = _uiState - - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage + private val logger = Logger("SignUpViewModel") - private val _isButtonLoading = MutableLiveData(false) - val isButtonLoading: LiveData - get() = _isButtonLoading - - private val _successLogin = MutableLiveData(false) - val successLogin: LiveData - get() = _successLogin + private val _uiState = MutableStateFlow( + SignUpUIState( + isFacebookAuthEnabled = config.getFacebookConfig().isEnabled(), + isGoogleAuthEnabled = config.getGoogleConfig().isEnabled(), + isMicrosoftAuthEnabled = config.getMicrosoftConfig().isEnabled(), + isSocialAuthEnabled = config.isSocialAuthEnabled(), + isLoading = true, + ) + ) + val uiState = _uiState.asStateFlow() - private val _validationError = MutableLiveData(false) - val validationError: LiveData - get() = _validationError + private val _uiMessage = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val uiMessage = _uiMessage.asSharedFlow() - private val optionalFields = mutableMapOf() - private val allFields = mutableListOf() + init { + collectAppUpgradeEvent() + } fun getRegistrationFields() { - _uiState.value = SignUpUIState.Loading + _uiState.update { it.copy(isLoading = true) } viewModelScope.launch { try { - val fields = interactor.getRegistrationFields() - _uiState.value = SignUpUIState.Fields( - fields = fields.filter { it.required }, - optionalFields = fields.filter { !it.required } - ) - optionalFields.clear() - allFields.clear() - allFields.addAll(fields) - optionalFields.putAll((fields.filter { !it.required }.associate { it.name to "" })) + val allFields = interactor.getRegistrationFields() + _uiState.update { state -> + state.copy( + allFields = allFields, + isLoading = false, + ) + } } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) + ) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_unknown_error) + ) + ) } } } } - fun register(mapFields: Map) { + fun register() { analytics.createAccountClickedEvent("") + val mapFields = uiState.value.allFields.associate { it.name to it.placeholder } + + mapOf(ApiConstants.HONOR_CODE to true.toString()) val resultMap = mapFields.toMutableMap() - optionalFields.forEach { (k, v) -> + uiState.value.allFields.filter { !it.required }.forEach { (k, _) -> if (mapFields[k].isNullOrEmpty()) { resultMap.remove(k) } } - _isButtonLoading.value = true - _validationError.value = false + _uiState.update { it.copy(isButtonLoading = true, validationError = false) } viewModelScope.launch { try { - val validationFields = interactor.validateRegistrationFields(resultMap.toMap()) + setErrorInstructions(emptyMap()) + val validationFields = interactor.validateRegistrationFields(mapFields) setErrorInstructions(validationFields.validationResult) if (validationFields.hasValidationError()) { - _validationError.value = true + _uiState.update { it.copy(validationError = true, isButtonLoading = false) } } else { + val socialAuth = uiState.value.socialAuth + if (socialAuth?.accessToken != null) { + resultMap[ApiConstants.ACCESS_TOKEN] = socialAuth.accessToken + resultMap[ApiConstants.PROVIDER] = socialAuth.authType.postfix + resultMap[ApiConstants.CLIENT_ID] = config.getOAuthClientId() + } interactor.register(resultMap.toMap()) - interactor.login( - resultMap.getValue(ApiConstants.EMAIL), - resultMap.getValue(ApiConstants.PASSWORD) - ) - setUserId() - analytics.registrationSuccessEvent("") - _successLogin.value = true + analytics.registrationSuccessEvent(socialAuth?.authType?.postfix.orEmpty()) + if (socialAuth == null) { + interactor.login( + resultMap.getValue(ApiConstants.EMAIL), + resultMap.getValue(ApiConstants.PASSWORD) + ) + setUserId() + _uiState.update { it.copy(successLogin = true, isButtonLoading = false) } + } else { + exchangeToken(socialAuth) + } } - _isButtonLoading.value = false } catch (e: Exception) { - _isButtonLoading.value = false + _uiState.update { it.copy(isButtonLoading = false) } if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) + ) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_unknown_error) + ) + ) + } + } + } + } + + fun socialAuth(fragment: Fragment, authType: AuthType) { + _uiState.update { it.copy(isLoading = true) } + viewModelScope.launch { + withContext(Dispatchers.IO) { + runCatching { + oAuthHelper.socialAuth(fragment, authType) } } + .getOrNull() + .checkToken() + } + } + + private suspend fun SocialAuthResponse?.checkToken() { + this?.accessToken?.let { token -> + if (token.isNotEmpty()) { + exchangeToken(this) + } else { + _uiState.update { it.copy(isLoading = false) } + } + } ?: _uiState.update { it.copy(isLoading = false) } + } + + private suspend fun exchangeToken(socialAuth: SocialAuthResponse) { + runCatching { + interactor.loginSocial(socialAuth.accessToken, socialAuth.authType) + }.onFailure { + _uiState.update { + val fields = it.allFields.toMutableList() + .filter { field -> field.type != RegistrationFieldType.PASSWORD } + updateField(ApiConstants.NAME, socialAuth.name) + updateField(ApiConstants.EMAIL, socialAuth.email) + setErrorInstructions(emptyMap()) + it.copy( + isLoading = false, + socialAuth = socialAuth, + allFields = fields + ) + } + }.onSuccess { + setUserId() + analytics.userLoginEvent(socialAuth.authType.methodName) + _uiState.update { it.copy(successLogin = true) } + logger.d { "Social login (${socialAuth.authType.methodName}) success" } } } private fun setErrorInstructions(errorMap: Map) { + val allFields = uiState.value.allFields val updatedFields = ArrayList(allFields.size) allFields.forEach { if (errorMap.containsKey(it.name)) { @@ -120,14 +207,21 @@ class SignUpViewModel( updatedFields.add(it.copy(errorInstructions = "")) } } - allFields.clear() - allFields.addAll(updatedFields) - _uiState.value = SignUpUIState.Fields( - updatedFields.filter { it.required }, - updatedFields.filter { !it.required } - ) + _uiState.update { state -> + state.copy( + allFields = updatedFields, + isLoading = false, + ) + } } + private fun collectAppUpgradeEvent() { + viewModelScope.launch { + appUpgradeNotifier.notifier.collect { event -> + _uiState.update { it.copy(appUpgradeEvent = event) } + } + } + } private fun setUserId() { preferencesManager.user?.let { @@ -135,10 +229,16 @@ class SignUpViewModel( } } + fun updateField(key: String, value: String) { + _uiState.update { + val updatedFields = uiState.value.allFields.toMutableList().map { field -> + if (field.name == key) { + field.copy(placeholder = value) + } else { + field + } + } + it.copy(allFields = updatedFields) + } + } } - -private enum class RegisterProvider(val keyName: String) { - GOOGLE("google-oauth2"), - AZURE("azuread-oauth2"), - FACEBOOK("facebook") -} \ No newline at end of file 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 new file mode 100644 index 000000000..0658396e2 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -0,0 +1,498 @@ +package org.openedx.auth.presentation.signup.compose + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +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.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.openedx.auth.R +import org.openedx.auth.data.model.AuthType +import org.openedx.auth.presentation.signup.SignUpUIState +import org.openedx.auth.presentation.ui.ExpandableText +import org.openedx.auth.presentation.ui.OptionalFields +import org.openedx.auth.presentation.ui.RequiredFields +import org.openedx.auth.presentation.ui.SocialAuthView +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.RegistrationField +import org.openedx.core.domain.model.RegistrationFieldType +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.SheetContent +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.isImeVisibleState +import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.rememberSaveableMap +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.core.R as coreR + +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) +@Composable +internal fun SignUpView( + windowSize: WindowSize, + uiState: SignUpUIState, + uiMessage: UIMessage?, + onBackClick: () -> Unit, + onFieldUpdated: (String, String) -> Unit, + onRegisterClick: (authType: AuthType) -> Unit, +) { + val scaffoldState = rememberScaffoldState() + val focusManager = LocalFocusManager.current + val bottomSheetScaffoldState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + skipHalfExpanded = true + ) + val coroutine = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current + var expandedList by rememberSaveable { + mutableStateOf(emptyList()) + } + val selectableNamesMap = rememberSaveableMap { + mutableStateMapOf() + } + val serverFieldName = rememberSaveable { + mutableStateOf("") + } + var showOptionalFields by rememberSaveable { + mutableStateOf(false) + } + val showErrorMap = rememberSaveableMap { + mutableStateMapOf() + } + val scrollState = rememberScrollState() + + val haptic = LocalHapticFeedback.current + + val listState = rememberLazyListState() + + var bottomDialogTitle by rememberSaveable { + mutableStateOf("") + } + + var searchValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + + val isImeVisible by isImeVisibleState() + + val fields = uiState.allFields.filter { it.required } + val optionalFields = uiState.allFields.filter { !it.required } + + LaunchedEffect(uiState.validationError) { + if (uiState.validationError) { + coroutine.launch { + scrollState.animateScrollTo(0, tween(300)) + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + } + } + + LaunchedEffect(uiState.socialAuth) { + if (uiState.socialAuth != null) { + coroutine.launch { + showErrorMap.clear() + scrollState.animateScrollTo(0, tween(300)) + } + } + } + + LaunchedEffect(bottomSheetScaffoldState.isVisible) { + if (!bottomSheetScaffoldState.isVisible) { + focusManager.clearFocus() + searchValue = TextFieldValue("") + } + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .semantics { + testTagsAsResourceId = true + } + .fillMaxSize() + .navigationBarsPadding(), + backgroundColor = MaterialTheme.appColors.background + ) { + + val topBarPadding by remember { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier + .width(560.dp) + .padding(bottom = 24.dp), + compact = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp) + ) + ) + } + val contentPaddings by remember { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier + .widthIn(Dp.Unspecified, 420.dp) + .padding( + top = 32.dp, + bottom = 40.dp + ), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp) + ) + ) + } + val buttonWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(232.dp, Dp.Unspecified), + compact = Modifier.fillMaxWidth() + ) + ) + } + + ModalBottomSheetLayout( + modifier = Modifier + .padding(bottom = if (isImeVisible && bottomSheetScaffoldState.isVisible) 120.dp else 0.dp) + .noRippleClickable { + if (bottomSheetScaffoldState.isVisible) { + coroutine.launch { + bottomSheetScaffoldState.hide() + } + } + }, + sheetState = bottomSheetScaffoldState, + sheetShape = MaterialTheme.appShapes.screenBackgroundShape, + scrimColor = Color.Black.copy(alpha = 0.4f), + sheetBackgroundColor = MaterialTheme.appColors.background, + sheetContent = { + SheetContent( + title = bottomDialogTitle, + searchValue = searchValue, + expandedList = expandedList, + listState = listState, + onItemClick = { item -> + onFieldUpdated(serverFieldName.value, item.value) + selectableNamesMap[serverFieldName.value] = item.name + coroutine.launch { + bottomSheetScaffoldState.hide() + } + }, + searchValueChanged = { + searchValue = TextFieldValue(it) + } + ) + } + ) { + Image( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.3f), + painter = painterResource(id = coreR.drawable.core_top_header), + contentScale = ContentScale.FillBounds, + contentDescription = null + ) + HandleUIMessage( + uiMessage = uiMessage, + scaffoldState = scaffoldState + ) + Column( + Modifier + .fillMaxWidth() + .padding(it) + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .then(topBarPadding), + contentAlignment = Alignment.CenterStart + ) { + Text( + modifier = Modifier + .testTag("txt_screen_title") + .fillMaxWidth(), + text = stringResource(id = coreR.string.core_register), + color = Color.White, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.titleMedium + ) + BackBtn( + modifier = Modifier.padding(end = 16.dp), + tint = Color.White + ) { + onBackClick() + } + } + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .background(MaterialTheme.appColors.background), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (uiState.isLoading) { + Box( + Modifier + .fillMaxSize(), contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else { + Column( + Modifier + .fillMaxHeight() + .verticalScroll(scrollState) + .displayCutoutForLandscape() + .then(contentPaddings), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column { + if (uiState.socialAuth != null) { + SocialSignedView(uiState.socialAuth.authType) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = stringResource( + id = R.string.auth_compete_registration + ), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleSmall + ) + } else { + Text( + modifier = Modifier + .testTag("txt_sign_up_title") + .fillMaxWidth(), + text = stringResource(id = R.string.auth_sign_up), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.displaySmall + ) + Text( + modifier = Modifier + .testTag("txt_sign_up_description") + .fillMaxWidth() + .padding(top = 4.dp), + text = stringResource( + id = R.string.auth_create_new_account + ), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleSmall + ) + } + } + RequiredFields( + fields = fields, + showErrorMap = showErrorMap, + selectableNamesMap = selectableNamesMap, + onSelectClick = { serverName, field, list -> + keyboardController?.hide() + serverFieldName.value = serverName + expandedList = list + coroutine.launch { + if (bottomSheetScaffoldState.isVisible) { + bottomSheetScaffoldState.hide() + } else { + bottomDialogTitle = field.label + showErrorMap[field.name] = false + bottomSheetScaffoldState.show() + } + } + }, + onFieldUpdated = onFieldUpdated + ) + if (optionalFields.isNotEmpty()) { + ExpandableText( + modifier = Modifier.testTag("txt_optional_field"), + isExpanded = showOptionalFields, + onClick = { + showOptionalFields = !showOptionalFields + } + ) + Surface(color = MaterialTheme.appColors.background) { + AnimatedVisibility(visible = showOptionalFields) { + OptionalFields( + fields = optionalFields, + showErrorMap = showErrorMap, + selectableNamesMap = selectableNamesMap, + onSelectClick = { serverName, field, list -> + keyboardController?.hide() + serverFieldName.value = + serverName + expandedList = list + coroutine.launch { + if (bottomSheetScaffoldState.isVisible) { + bottomSheetScaffoldState.hide() + } else { + bottomDialogTitle = field.label + showErrorMap[field.name] = false + bottomSheetScaffoldState.show() + } + } + }, + onFieldUpdated = onFieldUpdated, + ) + } + } + } + + if (uiState.isButtonLoading) { + Box( + Modifier + .fillMaxWidth() + .height(42.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else { + OpenEdXButton( + width = buttonWidth.testTag("btn_create_account"), + text = stringResource(id = R.string.auth_create_account), + onClick = { + showErrorMap.clear() + onRegisterClick(AuthType.PASSWORD) + } + ) + } + if (uiState.isSocialAuthEnabled && uiState.socialAuth == null) { + SocialAuthView( + modifier = buttonWidth, + isGoogleAuthEnabled = uiState.isGoogleAuthEnabled, + isFacebookAuthEnabled = uiState.isFacebookAuthEnabled, + isMicrosoftAuthEnabled = uiState.isMicrosoftAuthEnabled, + isSignIn = false, + ) { + onRegisterClick(it) + } + } + Spacer(Modifier.height(70.dp)) + } + } + } + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun RegistrationScreenPreview() { + OpenEdXTheme { + SignUpView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = SignUpUIState( + allFields = listOf(field, field, field.copy(required = false)), + ), + uiMessage = null, + onBackClick = {}, + onRegisterClick = {}, + onFieldUpdated = { _, _ -> }, + ) + } +} + +@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun RegistrationScreenTabletPreview() { + OpenEdXTheme { + SignUpView( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = SignUpUIState( + allFields = listOf(field, field, field.copy(required = false)), + ), + uiMessage = null, + onBackClick = {}, + onRegisterClick = {}, + onFieldUpdated = { _, _ -> }, + ) + } +} + +private val option = RegistrationField.Option("def", "Bachelor", "Android") + +private val field = RegistrationField( + "Fullname", + "Fullname", + RegistrationFieldType.TEXT, + "Fullname", + instructions = "Enter your fullname", + exposed = false, + required = 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 new file mode 100644 index 000000000..25a9434d1 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt @@ -0,0 +1,61 @@ +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.padding +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.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.openedx.auth.R +import org.openedx.auth.data.model.AuthType +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.R as coreR + +@Composable +internal fun SocialSignedView(authType: AuthType) { + Column( + modifier = Modifier + .background( + color = MaterialTheme.appColors.secondary, + 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 + ) + ) + Text( + modifier = Modifier.padding(top = 8.dp), + text = stringResource( + id = R.string.auth_social_signed_desc, + stringResource(id = coreR.string.app_name) + ) + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewSocialSignedView() { + OpenEdXTheme { + SocialSignedView(AuthType.GOOGLE) + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt new file mode 100644 index 000000000..70f2209ab --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt @@ -0,0 +1,84 @@ +package org.openedx.auth.presentation.sso + +import android.os.Bundle +import androidx.fragment.app.Fragment +import com.facebook.CallbackManager +import com.facebook.FacebookCallback +import com.facebook.FacebookException +import com.facebook.GraphRequest +import com.facebook.login.LoginManager +import com.facebook.login.LoginResult +import kotlinx.coroutines.suspendCancellableCoroutine +import org.openedx.auth.data.model.AuthType +import org.openedx.auth.domain.model.SocialAuthResponse +import org.openedx.core.ApiConstants +import org.openedx.core.extension.safeResume +import org.openedx.core.utils.Logger +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class FacebookAuthHelper { + + private val logger = Logger(TAG) + private val callbackManager = CallbackManager.Factory.create() + + suspend fun socialAuth(fragment: Fragment): SocialAuthResponse? = + suspendCancellableCoroutine { continuation -> + LoginManager.getInstance().registerCallback( + callbackManager, + object : FacebookCallback { + override fun onCancel() { + logger.d { "Facebook auth canceled" } + continuation.resume(SocialAuthResponse()) + } + + override fun onError(error: FacebookException) { + logger.e { "Facebook auth error: $error" } + continuation.resumeWithException(error) + } + + override fun onSuccess(result: LoginResult) { + logger.d { "Facebook auth success" } + GraphRequest.newMeRequest(result.accessToken) { obj, response -> + if (response?.error != null) { + continuation.cancel() + } else { + continuation.safeResume( + SocialAuthResponse( + accessToken = result.accessToken.token, + name = obj?.getString(ApiConstants.NAME) ?: "", + email = obj?.getString(ApiConstants.EMAIL) ?: "", + authType = AuthType.FACEBOOK, + ) + ) { + continuation.cancel() + } + } + }.also { + it.parameters = Bundle().apply { + putString("fields", "${ApiConstants.NAME}, ${ApiConstants.EMAIL}") + } + it.executeAsync() + } + } + } + ) + LoginManager.getInstance().logOut() + LoginManager.getInstance().logInWithReadPermissions( + fragment, + callbackManager, + PERMISSIONS_LIST + ) + } + + fun clear() { + runCatching { + LoginManager.getInstance().unregisterCallback(callbackManager) + } + } + + private companion object { + const val TAG = "FacebookAuthHelper" + val PERMISSIONS_LIST = listOf("email", "public_profile") + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt new file mode 100644 index 000000000..99985b882 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt @@ -0,0 +1,110 @@ +package org.openedx.auth.presentation.sso + +import android.accounts.Account +import android.app.Activity +import android.credentials.GetCredentialException +import android.os.Bundle +import androidx.annotation.WorkerThread +import androidx.credentials.Credential +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.exceptions.GetCredentialCancellationException +import com.google.android.gms.auth.GoogleAuthUtil +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException +import org.openedx.auth.data.model.AuthType +import org.openedx.auth.domain.model.SocialAuthResponse +import org.openedx.core.config.Config +import org.openedx.core.utils.Logger + +class GoogleAuthHelper(private val config: Config) { + + private val logger = Logger(TAG) + + private fun getAuthToken(activityContext: Activity, name: String): String? { + return runCatching { + GoogleAuthUtil.getToken( + activityContext, + Account(name, GoogleAuthUtil.GOOGLE_ACCOUNT_TYPE), + SCOPE + ) + }.getOrNull() + } + + private suspend fun getCredentials(activityContext: Activity): GoogleIdTokenCredential? { + return runCatching { + val credentialManager = CredentialManager.create(activityContext) + val googleIdOption = + GetSignInWithGoogleOption.Builder(config.getGoogleConfig().clientId).build() + val request: GetCredentialRequest = GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + val result = credentialManager.getCredential( + request = request, + context = activityContext, + ) + getGoogleIdToken(result.credential) + }.onFailure { + if (it is GetCredentialCancellationException && + it.type == GetCredentialException.TYPE_USER_CANCELED + ) { + return null + } + logger.e { "GetCredentials error: ${it.message}" } + }.getOrNull() + } + + private fun getGoogleIdToken(credential: Credential): GoogleIdTokenCredential? { + return when (credential) { + is GoogleIdTokenCredential -> { + parseToken(credential.data) + } + + is CustomCredential -> { + if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { + parseToken(credential.data) + } else { + null + } + } + + else -> { + logger.e { "Unknown credential type" } + null + } + } + } + + private fun parseToken(data: Bundle): GoogleIdTokenCredential? = + try { + GoogleIdTokenCredential.createFrom(data) + } catch (e: GoogleIdTokenParsingException) { + logger.e { "Token parsing exception: $e" } + null + } + + @WorkerThread + suspend fun socialAuth(activityContext: Activity): SocialAuthResponse? { + return getCredentials(activityContext)?.let { credentials -> + if (credentials.id.isNotBlank()) { + val token = getAuthToken(activityContext, credentials.id).orEmpty() + logger.d { token } + SocialAuthResponse( + accessToken = token, + name = credentials.displayName.orEmpty(), + email = credentials.id, + authType = AuthType.GOOGLE, + ) + } else { + return null + } + } + } + + private companion object { + const val TAG = "GoogleAuthHelper" + const val SCOPE = "oauth2: https://www.googleapis.com/auth/userinfo.email" + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt new file mode 100644 index 000000000..7cfcef591 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt @@ -0,0 +1,75 @@ +package org.openedx.auth.presentation.sso + +import android.app.Activity +import androidx.annotation.WorkerThread +import com.microsoft.identity.client.AcquireTokenParameters +import com.microsoft.identity.client.AuthenticationCallback +import com.microsoft.identity.client.IAuthenticationResult +import com.microsoft.identity.client.PublicClientApplication +import com.microsoft.identity.client.exception.MsalException +import kotlinx.coroutines.suspendCancellableCoroutine +import org.openedx.auth.data.model.AuthType +import org.openedx.auth.domain.model.SocialAuthResponse +import org.openedx.core.ApiConstants +import org.openedx.core.R +import org.openedx.core.extension.safeResume +import org.openedx.core.utils.Logger +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class MicrosoftAuthHelper { + private val logger = Logger(TAG) + + @WorkerThread + suspend fun socialAuth(activityContext: Activity): SocialAuthResponse? = + suspendCancellableCoroutine { continuation -> + val clientApplication = + PublicClientApplication.createMultipleAccountPublicClientApplication( + activityContext, + R.raw.microsoft_auth_config + ) + val params = AcquireTokenParameters.Builder() + .startAuthorizationFromActivity(activityContext) + .withScopes(SCOPES) + .withCallback(object : AuthenticationCallback { + override fun onSuccess(authenticationResult: IAuthenticationResult?) { + val claims = authenticationResult?.account?.claims + val name = + (claims?.getOrDefault(ApiConstants.NAME, "") as? String) + .orEmpty() + val email = + (claims?.getOrDefault(ApiConstants.EMAIL, "") as? String) + .orEmpty() + continuation.safeResume( + SocialAuthResponse( + accessToken = authenticationResult?.accessToken.orEmpty(), + name = name, + email = email, + authType = AuthType.MICROSOFT, + ) + ) { + continuation.cancel() + } + } + + override fun onError(exception: MsalException) { + logger.e { "Microsoft auth error: $exception" } + continuation.resumeWithException(exception) + } + + override fun onCancel() { + logger.d { "Microsoft auth canceled" } + continuation.resume(SocialAuthResponse()) + } + }).build() + clientApplication.accounts.forEach { + clientApplication.removeAccount(it) + } + clientApplication.acquireToken(params) + } + + private companion object { + const val TAG = "MicrosoftAuthHelper" + val SCOPES = listOf("User.Read", ApiConstants.EMAIL) + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt new file mode 100644 index 000000000..776df7c46 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt @@ -0,0 +1,30 @@ +package org.openedx.auth.presentation.sso + +import androidx.fragment.app.Fragment +import org.openedx.auth.data.model.AuthType +import org.openedx.auth.domain.model.SocialAuthResponse + +class OAuthHelper( + private val facebookAuthHelper: FacebookAuthHelper, + private val googleAuthHelper: GoogleAuthHelper, + private val microsoftAuthHelper: MicrosoftAuthHelper, +) { + /** + * SDK integration guides: + * https://developer.android.com/training/sign-in/credential-manager + * https://developers.facebook.com/docs/facebook-login/android/ + * https://github.com/AzureAD/microsoft-authentication-library-for-android + */ + internal suspend fun socialAuth(fragment: Fragment, authType: AuthType): SocialAuthResponse? { + return when (authType) { + AuthType.PASSWORD -> null + AuthType.GOOGLE -> googleAuthHelper.socialAuth(fragment.requireActivity()) + AuthType.FACEBOOK -> facebookAuthHelper.socialAuth(fragment) + AuthType.MICROSOFT -> microsoftAuthHelper.socialAuth(fragment.requireActivity()) + } + } + + fun clear() { + facebookAuthHelper.clear() + } +} 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 e00b56e6c..e875a4539 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 @@ -37,6 +37,7 @@ import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -60,9 +61,9 @@ import org.openedx.core.ui.theme.appTypography @Composable fun RequiredFields( fields: List, - mapFields: MutableMap, showErrorMap: MutableMap, selectableNamesMap: MutableMap, + onFieldUpdated: (String, String) -> Unit, onSelectClick: (String, RegistrationField, List) -> Unit ) { fields.forEach { field -> @@ -76,7 +77,7 @@ fun RequiredFields( if (!isErrorShown) { showErrorMap[serverName] = isErrorShown } - mapFields[serverName] = value + onFieldUpdated(serverName, value) } ) } @@ -85,6 +86,7 @@ fun RequiredFields( val linkedText = TextConverter.htmlTextToLinkedText(field.label) HyperlinkText( + modifier = Modifier.testTag("txt_${field.name}"), fullText = linkedText.text, hyperLinks = linkedText.links, linkTextColor = MaterialTheme.appColors.primary @@ -117,7 +119,7 @@ fun RequiredFields( if (!isErrorShown) { showErrorMap[serverName] = isErrorShown } - mapFields[serverName] = value + onFieldUpdated(serverName, value) } ) } @@ -132,12 +134,12 @@ fun RequiredFields( @Composable fun OptionalFields( fields: List, - mapFields: MutableMap, showErrorMap: MutableMap, selectableNamesMap: MutableMap, - onSelectClick: (String, RegistrationField, List) -> Unit + onSelectClick: (String, RegistrationField, List) -> Unit, + onFieldUpdated: (String, String) -> Unit, ) { - Column() { + Column { fields.forEach { field -> when (field.type) { RegistrationFieldType.TEXT, RegistrationFieldType.EMAIL, RegistrationFieldType.CONFIRM_EMAIL, RegistrationFieldType.PASSWORD -> { @@ -151,8 +153,7 @@ fun OptionalFields( showErrorMap[serverName] = isErrorShown } - mapFields[serverName] = - value + onFieldUpdated(serverName, value) } ) } @@ -195,11 +196,9 @@ fun OptionalFields( registrationField = field, onValueChanged = { serverName, value, isErrorShown -> if (!isErrorShown) { - showErrorMap[serverName] = - isErrorShown + showErrorMap[serverName] = isErrorShown } - mapFields[serverName] = - value + onFieldUpdated(serverName, value) } ) } @@ -214,6 +213,8 @@ fun OptionalFields( @Composable fun LoginTextField( modifier: Modifier = Modifier, + title: String, + description: String, onValueChanged: (String) -> Unit, imeAction: ImeAction = ImeAction.Next, keyboardActions: (FocusManager) -> Unit = { it.moveFocus(FocusDirection.Down) } @@ -225,8 +226,10 @@ fun LoginTextField( } val focusManager = LocalFocusManager.current Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.auth_email), + modifier = Modifier + .testTag("txt_email_label") + .fillMaxWidth(), + text = title, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.labelLarge ) @@ -244,7 +247,8 @@ fun LoginTextField( shape = MaterialTheme.appShapes.textFieldShape, placeholder = { Text( - text = stringResource(id = R.string.auth_example_email), + modifier = Modifier.testTag("txt_email_placeholder"), + text = description, color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.bodyMedium ) @@ -258,7 +262,7 @@ fun LoginTextField( }, textStyle = MaterialTheme.appTypography.bodyMedium, singleLine = true, - modifier = modifier + modifier = modifier.testTag("tf_email") ) } @@ -270,7 +274,7 @@ fun InputRegistrationField( onValueChanged: (String, String, Boolean) -> Unit ) { var inputRegistrationFieldValue by rememberSaveable { - mutableStateOf("") + mutableStateOf(registrationField.placeholder) } val focusManager = LocalFocusManager.current val visualTransformation = if (registrationField.type == RegistrationFieldType.PASSWORD) { @@ -300,7 +304,9 @@ fun InputRegistrationField( } Column { Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag("txt_${registrationField.name}_label") + .fillMaxWidth(), text = registrationField.label, style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textPrimary @@ -323,6 +329,7 @@ fun InputRegistrationField( shape = MaterialTheme.appShapes.textFieldShape, placeholder = { Text( + modifier = modifier.testTag("txt_${registrationField.name}_placeholder"), text = registrationField.label, color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.bodyMedium @@ -338,10 +345,11 @@ fun InputRegistrationField( }, textStyle = MaterialTheme.appTypography.bodyMedium, singleLine = isSingleLine, - modifier = modifier + modifier = modifier.testTag("tf_${registrationField.name}") ) Spacer(modifier = Modifier.height(6.dp)) Text( + modifier = Modifier.testTag("txt_${registrationField.name}_description"), text = helperText, style = MaterialTheme.appTypography.bodySmall, color = helperTextColor @@ -383,7 +391,9 @@ fun SelectableRegisterField( } ) { Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag("txt_${registrationField.name}_label") + .fillMaxWidth(), text = registrationField.label, style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textPrimary @@ -404,12 +414,14 @@ fun SelectableRegisterField( textStyle = MaterialTheme.appTypography.bodyMedium, onValueChange = { }, modifier = Modifier + .testTag("tf_${registrationField.name}") .fillMaxWidth() .noRippleClickable { onClick(registrationField.name, registrationField.options) }, placeholder = { Text( + modifier = Modifier.testTag("txt_${registrationField.name}_placeholder"), text = registrationField.label, color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.bodyMedium @@ -425,6 +437,7 @@ fun SelectableRegisterField( ) Spacer(modifier = Modifier.height(6.dp)) Text( + modifier = Modifier.testTag("txt_${registrationField.name}_description"), text = helperText, style = MaterialTheme.appTypography.bodySmall, color = helperTextColor @@ -434,6 +447,7 @@ fun SelectableRegisterField( @Composable fun ExpandableText( + modifier: Modifier = Modifier, isExpanded: Boolean, onClick: (Boolean) -> Unit ) { @@ -457,7 +471,7 @@ fun ExpandableText( val icon = Icons.Filled.ChevronRight Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .noRippleClickable { onClick(isExpanded) @@ -522,15 +536,13 @@ fun InputRegistrationFieldPreview() { private fun OptionalFieldsPreview() { OpenEdXTheme { Column(Modifier.background(MaterialTheme.appColors.background)) { + val optionalField = field.copy(required = false) OptionalFields( - fields = listOf(field, field, field), - mapFields = SnapshotStateMap(), + fields = List(3) { optionalField }, showErrorMap = SnapshotStateMap(), selectableNamesMap = SnapshotStateMap(), - onSelectClick = { _, _, _ -> - - } - + onSelectClick = { _, _, _ -> }, + onFieldUpdated = { _, _ -> } ) } } @@ -544,12 +556,10 @@ private fun RequiredFieldsPreview() { Column(Modifier.background(MaterialTheme.appColors.background)) { RequiredFields( fields = listOf(field, field, field), - mapFields = SnapshotStateMap(), showErrorMap = SnapshotStateMap(), selectableNamesMap = SnapshotStateMap(), - onSelectClick = { _, _, _ -> - - } + onSelectClick = { _, _, _ -> }, + onFieldUpdated = { _, _ -> } ) } } @@ -585,4 +595,4 @@ private val field = RegistrationField( restrictions = RegistrationField.Restrictions(), options = listOf(option, option), errorInstructions = "" -) \ No newline at end of file +) 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 new file mode 100644 index 000000000..c9d73662b --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt @@ -0,0 +1,144 @@ +package org.openedx.auth.presentation.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.auth.R +import org.openedx.auth.data.model.AuthType +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors + +@Composable +internal fun SocialAuthView( + modifier: Modifier = Modifier, + isGoogleAuthEnabled: Boolean = true, + isFacebookAuthEnabled: Boolean = true, + isMicrosoftAuthEnabled: Boolean = true, + isSignIn: Boolean = false, + onEvent: (AuthType) -> Unit, +) { + Column(modifier = modifier) { + if (isGoogleAuthEnabled) { + val stringRes = if (isSignIn) { + R.string.auth_google + } else { + R.string.auth_continue_google + } + OpenEdXOutlinedButton( + modifier = Modifier + .testTag("btn_google_auth") + .padding(top = 24.dp) + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primary, + textColor = Color.Unspecified, + onClick = { + onEvent(AuthType.GOOGLE) + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_auth_google), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + modifier = Modifier + .testTag("txt_google_auth") + .padding(start = 10.dp), + text = stringResource(id = stringRes) + ) + } + } + } + if (isFacebookAuthEnabled) { + val stringRes = if (isSignIn) { + R.string.auth_facebook + } else { + R.string.auth_continue_facebook + } + OpenEdXButton( + width = Modifier + .testTag("btn_facebook_auth") + .padding(top = 12.dp) + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.authFacebookButtonBackground, + onClick = { + onEvent(AuthType.FACEBOOK) + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_auth_facebook), + contentDescription = null, + tint = MaterialTheme.appColors.buttonText, + ) + Text( + modifier = Modifier + .testTag("txt_facebook_auth") + .padding(start = 10.dp), + color = MaterialTheme.appColors.buttonText, + text = stringResource(id = stringRes) + ) + } + } + } + if (isMicrosoftAuthEnabled) { + val stringRes = if (isSignIn) { + R.string.auth_microsoft + } else { + R.string.auth_continue_microsoft + } + OpenEdXButton( + width = Modifier + .testTag("btn_microsoft_auth") + .padding(top = 12.dp) + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.authMicrosoftButtonBackground, + onClick = { + onEvent(AuthType.MICROSOFT) + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_auth_microsoft), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + modifier = Modifier + .testTag("txt_microsoft_auth") + .padding(start = 10.dp), + color = MaterialTheme.appColors.buttonText, + text = stringResource(id = stringRes) + ) + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SocialAuthViewPreview() { + OpenEdXTheme { + SocialAuthView() {} + } +} diff --git a/auth/src/main/res/drawable/ic_auth_facebook.xml b/auth/src/main/res/drawable/ic_auth_facebook.xml new file mode 100644 index 000000000..b8e7aa393 --- /dev/null +++ b/auth/src/main/res/drawable/ic_auth_facebook.xml @@ -0,0 +1,9 @@ + + + diff --git a/auth/src/main/res/drawable/ic_auth_google.xml b/auth/src/main/res/drawable/ic_auth_google.xml new file mode 100644 index 000000000..95bbcc563 --- /dev/null +++ b/auth/src/main/res/drawable/ic_auth_google.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/auth/src/main/res/drawable/ic_auth_microsoft.xml b/auth/src/main/res/drawable/ic_auth_microsoft.xml new file mode 100644 index 000000000..ce31faab7 --- /dev/null +++ b/auth/src/main/res/drawable/ic_auth_microsoft.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/auth/src/main/res/values-uk/strings.xml b/auth/src/main/res/values-uk/strings.xml index 5d2384d9c..c2c34abef 100644 --- a/auth/src/main/res/values-uk/strings.xml +++ b/auth/src/main/res/values-uk/strings.xml @@ -1,9 +1,6 @@ - Увійти Зареєструватися - Зареєструватися - Реєстрація Забули пароль? Електронна пошта Неправильна E-mail адреса diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 57b333598..85eb3a47f 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -1,12 +1,15 @@ - - Sign in + + Courses and programs from the world\'s best universities in your pocket. + What do you want to learn? + Search our 3000+ courses + Explore all courses Sign up - Register - Registration Forgot password? Email Invalid email + Email or Username + Invalid email or username Password is too short Welcome back! Please authorize to continue. Show optional fields @@ -18,6 +21,16 @@ Check your email We have sent a password recover instructions to your email %s username@domain.com + Enter email or username Enter password Create new account. - \ No newline at end of file + Complete your registration + Sign in with Google + Sign in with Facebook + Sign in with Microsoft + Continue with Google + Continue with Facebook + Continue with Microsoft + You\'ve successfully signed in with %s. + We just need a little more information before you start learning with %s. + 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 895a87739..e80b93db7 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 @@ -14,6 +14,7 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.* import org.junit.After import org.junit.Assert.* @@ -21,6 +22,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.system.notifier.AppUpgradeNotifier import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -33,6 +35,7 @@ class RestorePasswordViewModelTest { private val resourceManager = mockk() private val interactor = mockk() private val analytics = mockk() + private val appUpgradeNotifier = mockk() //region parameters @@ -53,6 +56,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() } @After @@ -62,13 +66,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset empty email validation error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics) + val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(emptyEmail) } returns true every { analytics.resetPasswordClickedEvent(false) } returns Unit viewModel.passwordReset(emptyEmail) advanceUntilIdle() coVerify(exactly = 0) { interactor.passwordReset(any()) } verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -78,13 +83,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset invalid email validation error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics) + val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(invalidEmail) } returns true every { analytics.resetPasswordClickedEvent(false) } returns Unit viewModel.passwordReset(invalidEmail) advanceUntilIdle() coVerify(exactly = 0) { interactor.passwordReset(any()) } verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -94,13 +100,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset validation error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics) + val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } throws EdxError.ValidationException("error") every { analytics.resetPasswordClickedEvent(false) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -110,13 +117,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset no internet error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics) + val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } throws UnknownHostException() every { analytics.resetPasswordClickedEvent(false) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -126,13 +134,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset unknown error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics) + val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } throws Exception() every { analytics.resetPasswordClickedEvent(false) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -142,13 +151,14 @@ class RestorePasswordViewModelTest { @Test fun `unSuccess restore password`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics) + val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } returns false every { analytics.resetPasswordClickedEvent(false) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -159,13 +169,14 @@ class RestorePasswordViewModelTest { @Test fun `success restore password`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics) + val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } returns true every { analytics.resetPasswordClickedEvent(true) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } verify(exactly = 1) { analytics.resetPasswordClickedEvent(true) } + verify(exactly = 1) { appUpgradeNotifier.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 97a4689e9..16b5032c4 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 @@ -1,14 +1,6 @@ package org.openedx.auth.presentation.signin import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.auth.R -import org.openedx.auth.domain.interactor.AuthInteractor -import org.openedx.auth.presentation.AuthAnalytics -import org.openedx.core.UIMessage -import org.openedx.core.Validator -import org.openedx.core.domain.model.User -import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -16,6 +8,7 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -23,11 +16,26 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.auth.R +import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.sso.OAuthHelper +import org.openedx.core.UIMessage +import org.openedx.core.Validator +import org.openedx.core.config.Config +import org.openedx.core.config.FacebookConfig +import org.openedx.core.config.GoogleConfig +import org.openedx.core.config.MicrosoftConfig +import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.system.EdxError +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.notifier.AppUpgradeNotifier import java.net.UnknownHostException import org.openedx.core.R as CoreRes @@ -39,16 +47,19 @@ class SignInViewModelTest { private val dispatcher = StandardTestDispatcher() + private val config = mockk() private val validator = mockk() private val resourceManager = mockk() private val preferencesManager = mockk() private val interactor = mockk() private val analytics = mockk() + private val appUpgradeNotifier = mockk() + private val oAuthHelper = mockk() private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" - private val invalidEmail = "Invalid email" + private val invalidEmailOrUsername = "Invalid email or username" private val invalidPassword = "Password too short" private val user = User(0, "", "", "") @@ -59,8 +70,14 @@ class SignInViewModelTest { every { resourceManager.getString(CoreRes.string.core_error_invalid_grant) } returns invalidCredential every { resourceManager.getString(CoreRes.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(CoreRes.string.core_error_unknown_error) } returns somethingWrong - every { resourceManager.getString(R.string.auth_invalid_email) } returns invalidEmail + 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 { config.isPreLoginExperienceEnabled() } returns false + every { config.isSocialAuthEnabled() } returns false + every { config.getFacebookConfig() } returns FacebookConfig() + every { config.getGoogleConfig() } returns GoogleConfig() + every { config.getMicrosoftConfig() } returns MicrosoftConfig() } @After @@ -70,88 +87,134 @@ class SignInViewModelTest { @Test fun `login empty credentials validation error`() = runTest { - every { validator.isEmailValid(any()) } returns false + every { validator.isEmailOrUserNameValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) viewModel.login("", "") coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage - - assertEquals(invalidEmail, message.message) - assert(viewModel.showProgress.value != true) - assert(viewModel.loginSuccess.value != true) + val uiState = viewModel.uiState.value + assertEquals(invalidEmailOrUsername, message.message) + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) } @Test fun `login invalid email validation error`() = runTest { - every { validator.isEmailValid(any()) } returns false + every { validator.isEmailOrUserNameValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) viewModel.login("acc@test.o", "") coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage - - assertEquals(invalidEmail, message.message) - assert(viewModel.showProgress.value != true) - assert(viewModel.loginSuccess.value != true) + val uiState = viewModel.uiState.value + assertEquals(invalidEmailOrUsername, message.message) + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) } @Test fun `login empty password validation error`() = runTest { - every { validator.isEmailValid(any()) } returns true + every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit coVerify(exactly = 0) { interactor.login(any(), any()) } - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) viewModel.login("acc@test.org", "") verify(exactly = 0) { analytics.setUserIdForSession(any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val uiState = viewModel.uiState.value assertEquals(invalidPassword, message.message) - assert(viewModel.showProgress.value != true) - assert(viewModel.loginSuccess.value != true) + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) } @Test fun `login invalid password validation error`() = runTest { - every { validator.isEmailValid(any()) } returns true + every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) viewModel.login("acc@test.org", "ed") coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage - + val uiState = viewModel.uiState.value assertEquals(invalidPassword, message.message) - assert(viewModel.showProgress.value != true) - assert(viewModel.loginSuccess.value != true) + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) } @Test fun `login success`() = runTest { - every { validator.isEmailValid(any()) } returns true + every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true every { analytics.userLoginEvent(any()) } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) coEvery { interactor.login("acc@test.org", "edx") } returns Unit viewModel.login("acc@test.org", "edx") advanceUntilIdle() @@ -159,76 +222,106 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 1) { analytics.userLoginEvent(any()) } verify(exactly = 1) { analytics.setUserIdForSession(any()) } - - assertEquals(false, viewModel.showProgress.value) - assertEquals(true, viewModel.loginSuccess.value) + verify(exactly = 1) { appUpgradeNotifier.notifier } + val uiState = viewModel.uiState.value + assertFalse(uiState.showProgress) + assert(uiState.loginSuccess) assertEquals(null, viewModel.uiMessage.value) } @Test fun `login network error`() = runTest { - every { validator.isEmailValid(any()) } returns true + every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) coEvery { interactor.login("acc@test.org", "edx") } throws UnknownHostException() viewModel.login("acc@test.org", "edx") advanceUntilIdle() coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - assertEquals(false, viewModel.showProgress.value) - assert(viewModel.loginSuccess.value != true) + val uiState = viewModel.uiState.value + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) assertEquals(noInternet, message?.message) } @Test fun `login invalid grant error`() = runTest { - every { validator.isEmailValid(any()) } returns true + every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) coEvery { interactor.login("acc@test.org", "edx") } throws EdxError.InvalidGrantException() viewModel.login("acc@test.org", "edx") advanceUntilIdle() coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage - - assertEquals(false, viewModel.showProgress.value) - assert(viewModel.loginSuccess.value != true) + val uiState = viewModel.uiState.value + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) assertEquals(invalidCredential, message.message) } @Test fun `login unknown exception`() = runTest { - every { validator.isEmailValid(any()) } returns true + every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) coEvery { interactor.login("acc@test.org", "edx") } throws IllegalStateException() viewModel.login("acc@test.org", "edx") advanceUntilIdle() coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage - - assertEquals(false, viewModel.showProgress.value) - assert(viewModel.loginSuccess.value != true) + val uiState = viewModel.uiState.value + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) assertEquals(somethingWrong, message.message) } - } 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 fbd7f4c4a..bd048902c 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 @@ -1,16 +1,6 @@ package org.openedx.auth.presentation.signup import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.auth.data.model.ValidationFields -import org.openedx.auth.domain.interactor.AuthInteractor -import org.openedx.auth.presentation.AuthAnalytics -import org.openedx.core.ApiConstants -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.domain.model.RegistrationFieldType -import org.openedx.core.domain.model.User -import org.openedx.core.system.ResourceManager import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -18,6 +8,9 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -25,11 +18,29 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.auth.data.model.ValidationFields +import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.sso.OAuthHelper +import org.openedx.core.ApiConstants +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.config.FacebookConfig +import org.openedx.core.config.GoogleConfig +import org.openedx.core.config.MicrosoftConfig +import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences +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 java.net.UnknownHostException @@ -40,21 +51,25 @@ class SignUpViewModelTest { val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() private val dispatcher = StandardTestDispatcher() + private val config = mockk() private val resourceManager = mockk() private val preferencesManager = mockk() private val interactor = mockk() private val analytics = mockk() + private val appUpgradeNotifier = mockk() + private val oAuthHelper = mockk() //region parameters private val parametersMap = mapOf( ApiConstants.EMAIL to "user@gmail.com", - ApiConstants.PASSWORD to "password123" + ApiConstants.PASSWORD to "password123", + "honor_code" to "true", ) private val listOfFields = listOf( RegistrationField( - "", + ApiConstants.EMAIL, "", RegistrationFieldType.TEXT, "", @@ -66,7 +81,7 @@ class SignUpViewModelTest { ), RegistrationField( - "", + ApiConstants.PASSWORD, "", RegistrationFieldType.TEXT, "", @@ -91,7 +106,11 @@ 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 { config.isSocialAuthEnabled() } returns false + every { config.getFacebookConfig() } returns FacebookConfig() + every { config.getGoogleConfig() } returns GoogleConfig() + every { config.getMicrosoftConfig() } returns MicrosoftConfig() } @After @@ -101,33 +120,60 @@ class SignUpViewModelTest { @Test fun `register has validation errors`() = runTest { - val viewModel = SignUpViewModel(interactor, resourceManager, analytics, preferencesManager) + val viewModel = SignUpViewModel( + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) coEvery { interactor.validateRegistrationFields(parametersMap) } returns ValidationFields( parametersMap ) + coEvery { interactor.getRegistrationFields() } returns listOfFields every { analytics.createAccountClickedEvent(any()) } returns Unit coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login("", "") } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - viewModel.register(parametersMap) + viewModel.getRegistrationFields() + advanceUntilIdle() + parametersMap.forEach { + viewModel.updateField(it.key, it.value) + } + viewModel.register() advanceUntilIdle() coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } verify(exactly = 1) { analytics.createAccountClickedEvent(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 } - assertEquals(true, viewModel.validationError.value) - assert(viewModel.successLogin.value != true) - assert(viewModel.isButtonLoading.value != true) - assertEquals(null, viewModel.uiMessage.value) + assertEquals(true, viewModel.uiState.value.validationError) + assertFalse(viewModel.uiState.value.successLogin) + assertFalse(viewModel.uiState.value.isButtonLoading) } @Test fun `register no internet error`() = runTest { - val viewModel = SignUpViewModel(interactor, resourceManager, analytics, preferencesManager) + val viewModel = SignUpViewModel( + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) + val deferred = async { viewModel.uiMessage.first() } + coEvery { interactor.validateRegistrationFields(parametersMap) } throws UnknownHostException() + coEvery { interactor.getRegistrationFields() } returns listOfFields coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login( @@ -138,56 +184,80 @@ class SignUpViewModelTest { every { analytics.createAccountClickedEvent(any()) } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - viewModel.register(parametersMap) + viewModel.getRegistrationFields() + advanceUntilIdle() + parametersMap.forEach { + viewModel.updateField(it.key, it.value) + } + viewModel.register() advanceUntilIdle() verify(exactly = 1) { analytics.createAccountClickedEvent(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 } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - assertEquals(false, viewModel.validationError.value) - assert(viewModel.successLogin.value != true) - assert(viewModel.isButtonLoading.value != true) - assertEquals(noInternet, message?.message) + assertFalse(viewModel.uiState.value.validationError) + assertFalse(viewModel.uiState.value.successLogin) + assertFalse(viewModel.uiState.value.isButtonLoading) + assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } @Test fun `something went wrong error`() = runTest { - val viewModel = SignUpViewModel(interactor, resourceManager, analytics, preferencesManager) + val viewModel = SignUpViewModel( + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) + val deferred = async { viewModel.uiMessage.first() } + coEvery { interactor.validateRegistrationFields(parametersMap) } throws Exception() coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login("", "") } returns Unit every { analytics.createAccountClickedEvent(any()) } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - viewModel.register(parametersMap) + viewModel.register() advanceUntilIdle() verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.createAccountClickedEvent(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 } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - assertEquals(false, viewModel.validationError.value) - assert(viewModel.successLogin.value != true) - assert(viewModel.isButtonLoading.value != true) - assertEquals(somethingWrong, message?.message) + assertFalse(viewModel.uiState.value.validationError) + assertFalse(viewModel.uiState.value.successLogin) + assertFalse(viewModel.uiState.value.isButtonLoading) + assertEquals(somethingWrong, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } @Test fun `success register`() = runTest { - val viewModel = SignUpViewModel(interactor, resourceManager, analytics, preferencesManager) + val viewModel = SignUpViewModel( + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) coEvery { interactor.validateRegistrationFields(parametersMap) } returns ValidationFields( emptyMap() ) every { analytics.createAccountClickedEvent(any()) } returns Unit every { analytics.registrationSuccessEvent(any()) } returns Unit + coEvery { interactor.getRegistrationFields() } returns listOfFields coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login( @@ -197,7 +267,12 @@ class SignUpViewModelTest { } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - viewModel.register(parametersMap) + viewModel.getRegistrationFields() + advanceUntilIdle() + parametersMap.forEach { + viewModel.updateField(it.key, it.value) + } + viewModel.register() advanceUntilIdle() verify(exactly = 1) { analytics.setUserIdForSession(any()) } coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } @@ -205,53 +280,81 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 1) { analytics.createAccountClickedEvent(any()) } verify(exactly = 1) { analytics.registrationSuccessEvent(any()) } + verify(exactly = 1) { appUpgradeNotifier.notifier } - assertEquals(false, viewModel.validationError.value) - assertEquals(false, viewModel.isButtonLoading.value) - assertEquals(null, viewModel.uiMessage.value) - assertEquals(true, viewModel.successLogin.value) + assertFalse(viewModel.uiState.value.validationError) + assertFalse(viewModel.uiState.value.isButtonLoading) + assertTrue(viewModel.uiState.value.successLogin) } @Test fun `getRegistrationFields no internet error`() = runTest { - val viewModel = SignUpViewModel(interactor, resourceManager, analytics, preferencesManager) + val viewModel = SignUpViewModel( + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) + val deferred = async { viewModel.uiMessage.first() } + coEvery { interactor.getRegistrationFields() } throws UnknownHostException() viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } + verify(exactly = 1) { appUpgradeNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - assert(viewModel.uiState.value is SignUpUIState.Loading) - assertEquals(noInternet, message?.message) + assertTrue(viewModel.uiState.value.isLoading) + assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } @Test fun `getRegistrationFields unknown error`() = runTest { - val viewModel = SignUpViewModel(interactor, resourceManager, analytics, preferencesManager) + val viewModel = SignUpViewModel( + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) + val deferred = async { viewModel.uiMessage.first() } + coEvery { interactor.getRegistrationFields() } throws Exception() viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } + verify(exactly = 1) { appUpgradeNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - assert(viewModel.uiState.value is SignUpUIState.Loading) - assertEquals(somethingWrong, message?.message) + assertTrue(viewModel.uiState.value.isLoading) + assertEquals(somethingWrong, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } @Test fun `getRegistrationFields success`() = runTest { - val viewModel = SignUpViewModel(interactor, resourceManager, analytics, preferencesManager) + val viewModel = SignUpViewModel( + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, + courseId = "", + ) coEvery { interactor.getRegistrationFields() } returns listOfFields viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } + verify(exactly = 1) { appUpgradeNotifier.notifier } //val fields = viewModel.uiState.value as? SignUpUIState.Fields - assert(viewModel.uiState.value is SignUpUIState.Fields) - assertEquals(null, viewModel.uiMessage.value) + assertFalse(viewModel.uiState.value.isLoading) } - -} \ No newline at end of file +} diff --git a/build.gradle b/build.gradle index 3eef5969f..462369006 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,8 @@ +import org.edx.builder.ConfigHelper + +import java.util.regex.Matcher +import java.util.regex.Pattern + buildscript { ext { kotlin_version = '1.8.21' @@ -8,8 +13,8 @@ buildscript { } plugins { - id 'com.android.application' version '8.1.0' apply false - id 'com.android.library' version '8.1.0' apply false + id 'com.android.application' version '8.1.1' apply false + id 'com.android.library' version '8.1.1' 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 @@ -27,7 +32,7 @@ ext { fragment_version = "1.6.1" constraintlayout_version = "2.1.4" viewpager2_version = "1.0.0" - media3 = "1.1.1" + media3_version = "1.1.1" youtubeplayer_version = "11.1.0" firebase_version = "32.1.0" @@ -47,8 +52,35 @@ ext { window_version = '1.1.0' + in_app_review = '2.0.1' + + extented_spans_version = "1.3.0" + + configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) + //testing mockk_version = '1.13.3' android_arch_version = '2.2.0' junit_version = '4.13.2' -} \ No newline at end of file +} + +def getCurrentFlavor() { + String tskReqStr = getGradle().getStartParameter().getTaskRequests().toString() + Pattern pattern + if (tskReqStr.contains("assemble")) // to run ./gradlew assembleRelease to build APK + pattern = Pattern.compile("assemble(\\w+)(Release|Debug)") + else if (tskReqStr.contains("bundle")) // to run ./gradlew bundleRelease to build .aab + pattern = Pattern.compile("bundle(\\w+)(Release|Debug)") + else + pattern = Pattern.compile("generate(\\w+)(Release|Debug)") + Matcher matcher = pattern.matcher(tskReqStr) + if (matcher.find()) { + return matcher.group(1).toLowerCase() + } else { + return "" + } +} + +task generateMockedRawFile() { + doLast { configHelper.generateMicrosoftConfig() } +} diff --git a/buildSrc/.gitignore b/buildSrc/.gitignore new file mode 100644 index 000000000..84c048a73 --- /dev/null +++ b/buildSrc/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle new file mode 100644 index 000000000..9cc60ef40 --- /dev/null +++ b/buildSrc/build.gradle @@ -0,0 +1,9 @@ +repositories { + mavenCentral() +} + +dependencies { + implementation localGroovy() + implementation gradleApi() + implementation 'org.yaml:snakeyaml:1.33' +} diff --git a/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy b/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy new file mode 100644 index 000000000..ed78319bc --- /dev/null +++ b/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy @@ -0,0 +1,98 @@ +package org.edx.builder + +import groovy.json.JsonBuilder +import org.yaml.snakeyaml.Yaml + +class ConfigHelper { + def CONFIG_SETTINGS_YAML_FILENAME = 'config_settings.yaml' + def DEFAULT_CONFIG_PATH = './default_config/' + CONFIG_SETTINGS_YAML_FILENAME + def CONFIG_DIRECTORY = "config_directory" + def CONFIG_MAPPING = "config_mapping" + def MAPPINGS_FILENAME = 'file_mappings.yaml' + def ANDROID_CONFIG_KEY = "android" + def FILES_CONFIG_KEY = "files" + + def configDir = "" + def projectDir = "" + + ConfigHelper(projectDir, buildType) { + def environment + if (buildType == 'develop' || buildType == '') { + environment = 'dev' + } else { + environment = buildType + } + this.projectDir = projectDir + def configFile = new File(CONFIG_SETTINGS_YAML_FILENAME) + if (!configFile.exists()) { + // parse default configurations if `config_settings.yaml` doesn't exist + println("Configurations are missing at " + configFile.path) + println("Parsing Default configurations from " + DEFAULT_CONFIG_PATH) + configFile = new File(DEFAULT_CONFIG_PATH) + } + if (!configFile.exists()) { + throw new Exception("Configurations are missing at " + configFile.path) + } + + def config = new Yaml().load(configFile.newInputStream()) + if (config[CONFIG_DIRECTORY] && config[CONFIG_MAPPING][environment]) { + configDir = config[CONFIG_DIRECTORY] + "/" + config[CONFIG_MAPPING][environment] + } else { + throw new Exception(environment + "key doesn't exist in " + configFile.path) + } + } + + def fetchConfig() { + def configFilesMapping = new File(configDir + "/" + MAPPINGS_FILENAME) + if (!configFilesMapping.exists()) { + throw new Exception("Inappropriate config directory format: $configFilesMapping") + } + def configMappingFiles = new Yaml().load(configFilesMapping.newInputStream()) + def androidConfigFiles = configMappingFiles.getOrDefault(ANDROID_CONFIG_KEY, {}).getOrDefault(FILES_CONFIG_KEY, []) + def androidConfigs = new LinkedHashMap() + androidConfigFiles.each { file -> + def configFile = new File(configDir + "/" + file) + if (configFile.exists()) { + def config = new Yaml().load(configFile.newInputStream()) + androidConfigs.putAll(config) + } + } + return androidConfigs + } + + def generateConfigJson() { + def config = fetchConfig() + def configJsonDir = new File(projectDir.path + "/core/assets/config") + configJsonDir.mkdirs() + def jsonWriter = new FileWriter(configJsonDir.path + "/config.json") + def builder = new JsonBuilder(config) + jsonWriter.withWriter { + builder.writeTo(it) + } + } + + def generateMicrosoftConfig() { + def config = fetchConfig() + def applicationId = config.getOrDefault("APPLICATION_ID", "") + def clientId = "" + def packageSign = "" + def microsoft = config.get("MICROSOFT") + if (microsoft) { + packageSign = microsoft.getOrDefault("PACKAGE_SIGNATURE", "") + clientId = microsoft.getOrDefault("CLIENT_ID", "") + } + def microsoftConfigsJsonPath = projectDir.path + "/core/src/main/res/raw/" + new File(microsoftConfigsJsonPath).mkdirs() + def sign = URLEncoder.encode(packageSign, "UTF-8") + def configJson = [ + client_id : clientId, + authorization_user_agent : "DEFAULT", + redirect_uri : "msauth://$applicationId/$sign", + account_mode : "MULTIPLE", + broker_redirect_uri_registered: false + ] + new FileWriter(microsoftConfigsJsonPath + "/microsoft_auth_config.json").withWriter { + it.write(new JsonBuilder(configJson).toPrettyString()) + } + } +} diff --git a/config.yaml b/config.yaml deleted file mode 100644 index 09972a3b6..000000000 --- a/config.yaml +++ /dev/null @@ -1,42 +0,0 @@ -environments: - DEV: - URLS: - API_HOST_URL: "https://dev-example.com/" - privacyPolicy: "https://dev-example.com/privacy" - termsOfService: "https://dev-example.com/tos" - contactUs: "https://dev-example.com/contact" - FEEDBACK_EMAIL_ADDRESS: "support@example.com" - OAUTH_CLIENT_ID: "DEV_OAUTH_CLIENT_ID" - FIREBASE: - PROJECT_ID: "" - APPLICATION_ID: "" - API_KEY: "" - GCM_SENDER_ID: "" - STAGE: - URLS: - API_HOST_URL: "http://stage-example.com/" - privacyPolicy: "http://stage-example.com/privacy" - termsOfService: "http://stage-example.com/tos" - contactUs: "http://stage-example.com/contact" - FEEDBACK_EMAIL_ADDRESS: "support@example.com" - OAUTH_CLIENT_ID: "STAGE_OAUTH_CLIENT_ID" - FIREBASE: - PROJECT_ID: "" - APPLICATION_ID: "" - API_KEY: "" - GCM_SENDER_ID: "" - PROD: - URLS: - API_HOST_URL: "https://example.com/" - privacyPolicy: "https://example.com/privacy" - termsOfService: "https://example.com/tos" - contactUs: "https://example.com/contact" - FEEDBACK_EMAIL_ADDRESS: "support@example.com" - OAUTH_CLIENT_ID: "PROD_OAUTH_CLIENT_ID" - FIREBASE: - PROJECT_ID: "" - APPLICATION_ID: "" - API_KEY: "" - GCM_SENDER_ID: "" -platformName: "OpenEdX" -platformFullName: "OpenEdX" \ No newline at end of file diff --git a/core/.gitignore b/core/.gitignore index 42afabfd2..7dae43452 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -1 +1,3 @@ -/build \ No newline at end of file +/build +/assets/config/ +microsoft_auth_config.json diff --git a/core/build.gradle b/core/build.gradle index 6bce96edf..93928bfc8 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,5 +1,3 @@ -import org.yaml.snakeyaml.Yaml - buildscript { repositories { mavenCentral() @@ -17,8 +15,9 @@ plugins { id 'kotlin-kapt' } - -def config = new Yaml().load(new File("config.yaml").newInputStream()) +def currentFlavour = getCurrentFlavor() +def config = configHelper.fetchConfig() +def platformName = config.getOrDefault("PLATFORM_NAME", "OpenEdx").toLowerCase() android { compileSdk 34 @@ -37,66 +36,35 @@ android { productFlavors { prod { dimension 'env' - - def envMap = config.environments.find { it.key == "PROD" } - def clientId = envMap.value.OAUTH_CLIENT_ID - def envUrls = envMap.value.URLS - def firebase = getFirebaseConfig(envMap) - - buildConfigField "String", "BASE_URL", "\"${envUrls.API_HOST_URL}\"" - buildConfigField "String", "CLIENT_ID", "\"${clientId}\"" - buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" - buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" - buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" - resValue "string", "google_app_id", firebase.appId - resValue "string", "platform_name", config.platformName - resValue "string", "platform_full_name", config.platformFullName - resValue "string", "privacy_policy_link", envUrls.privacyPolicy - resValue "string", "terms_of_service_link", envUrls.termsOfService - resValue "string", "contact_us_link", envUrls.contactUs - resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS + insertBuildConfigFields(currentFlavour, it) } develop { dimension 'env' - - def envMap = config.environments.find { it.key == "DEV" } - def clientId = envMap.value.OAUTH_CLIENT_ID - def envUrls = envMap.value.URLS - def firebase = getFirebaseConfig(envMap) - - buildConfigField "String", "BASE_URL", "\"${envUrls.API_HOST_URL}\"" - buildConfigField "String", "CLIENT_ID", "\"${clientId}\"" - buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" - buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" - buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" - resValue "string", "google_app_id", firebase.appId - resValue "string", "platform_name", config.platformName - resValue "string", "platform_full_name", config.platformFullName - resValue "string", "privacy_policy_link", envUrls.privacyPolicy - resValue "string", "terms_of_service_link", envUrls.termsOfService - resValue "string", "contact_us_link", envUrls.contactUs - resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS + insertBuildConfigFields(currentFlavour, it) } stage { dimension 'env' + insertBuildConfigFields(currentFlavour, it) + } + } - def envMap = config.environments.find { it.key == "STAGE" } - def clientId = envMap.value.OAUTH_CLIENT_ID - def envUrls = envMap.value.URLS - def firebase = getFirebaseConfig(envMap) - - buildConfigField "String", "BASE_URL", "\"${envUrls.API_HOST_URL}\"" - buildConfigField "String", "CLIENT_ID", "\"${clientId}\"" - buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" - buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" - buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" - resValue "string", "google_app_id", firebase.appId - resValue "string", "platform_name", config.platformName - resValue "string", "platform_full_name", config.platformFullName - resValue "string", "privacy_policy_link", envUrls.privacyPolicy - resValue "string", "terms_of_service_link", envUrls.termsOfService - resValue "string", "contact_us_link", envUrls.contactUs - resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS + sourceSets { + prod { + java.srcDirs = ["src/$platformName"] + res.srcDirs = ["src/$platformName/res"] + } + develop { + java.srcDirs = ["src/$platformName"] + res.srcDirs = ["src/$platformName/res"] + } + stage { + java.srcDirs = ["src/$platformName"] + res.srcDirs = ["src/$platformName/res"] + } + main { + assets { + srcDirs 'src/main/assets', 'assets' + } } } @@ -181,35 +149,47 @@ dependencies { api "com.google.firebase:firebase-crashlytics-ktx" api "com.google.firebase:firebase-analytics-ktx" + //Play In-App Review + api "com.google.android.play:review-ktx:$in_app_review" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } -class FirebaseConfig { - public String projectId = "" - public String appId = "" - public String apiKey = "" - public String gcmSenderId = "" +def insertBuildConfigFields(currentFlavour, buildType) { + if (currentFlavour == buildType.name) { + configHelper.generateConfigJson() + configHelper.generateMicrosoftConfig() + } + def config = configHelper.fetchConfig() + def platformName = config.getOrDefault("PLATFORM_NAME", "") + def platformFullName = config.getOrDefault("PLATFORM_FULL_NAME", "") + + buildType.resValue "string", "platform_name", platformName + buildType.resValue "string", "platform_full_name", platformFullName + insertFacebookBuildConfigFields(config, buildType) + insertMicrosoftManifestPlaceholder(config, buildType) } -def getFirebaseConfig(envMap) { - FirebaseConfig config = new FirebaseConfig() - if (envMap.value["FIREBASE"] != null) { - config.projectId = setValue(envMap.value["FIREBASE"]["PROJECT_ID"]) - config.appId = setValue(envMap.value["FIREBASE"]["APPLICATION_ID"]) - config.apiKey = setValue(envMap.value["FIREBASE"]["API_KEY"]) - config.gcmSenderId = setValue(envMap.value["FIREBASE"]["GCM_SENDER_ID"]) +static def insertFacebookBuildConfigFields(config, buildType) { + def facebook = config.get("FACEBOOK") + def facebookAppId = "" + def facebookClientToken = "" + if (facebook) { + facebookAppId = facebook.getOrDefault("FACEBOOK_APP_ID", "") + facebookClientToken = facebook.getOrDefault("CLIENT_TOKEN", "") } - return config + buildType.resValue "string", "facebook_app_id", facebookAppId + buildType.resValue "string", "fb_login_protocol_scheme", "fb$facebookAppId" + buildType.resValue "string", "facebook_client_token", facebookClientToken } -def setValue(value) { - def result - if (value == null) { - result = "" - } else { - result = value +static def insertMicrosoftManifestPlaceholder(config, buildType) { + def microsoft = config.get("MICROSOFT") + def microsoftPackageSignature = "" + if (microsoft) { + microsoftPackageSignature = microsoft.getOrDefault("PACKAGE_SIGNATURE", "") } - return result + buildType.manifestPlaceholders = [microsoftSignature: microsoftPackageSignature] } diff --git a/core/consumer-rules.pro b/core/consumer-rules.pro index e69de29bb..894a21021 100644 --- a/core/consumer-rules.pro +++ b/core/consumer-rules.pro @@ -0,0 +1,2 @@ +-dontwarn java.lang.invoke.StringConcatFactory +-dontwarn org.openedx.core.R$string \ No newline at end of file diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro index 481bb4348..a6be9313d 100644 --- a/core/proguard-rules.pro +++ b/core/proguard-rules.pro @@ -18,4 +18,6 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +-dontwarn java.lang.invoke.StringConcatFactory \ No newline at end of file diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml index 8c4c98268..0caf8d00a 100644 --- a/core/src/main/AndroidManifest.xml +++ b/core/src/main/AndroidManifest.xml @@ -1,5 +1,50 @@ - + + - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index 82486f593..558df5434 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -3,6 +3,7 @@ package org.openedx.core object ApiConstants { const val URL_LOGIN = "/oauth2/login/" const val URL_ACCESS_TOKEN = "/oauth2/access_token/" + const val URL_EXCHANGE_TOKEN = "/oauth2/exchange_access_token/{auth_type}/" const val GET_USER_PROFILE = "/api/mobile/v0.5/my_user_info" const val URL_REVOKE_TOKEN = "/oauth2/revoke_token/" const val URL_REGISTRATION_FIELDS = "/user_api/v1/account/registration" @@ -12,10 +13,21 @@ object ApiConstants { const val GRANT_TYPE_PASSWORD = "password" - const val TOKEN_TYPE_REFRESH = "refresh_token" - const val TOKEN_TYPE_BEARER = "Bearer" + const val TOKEN_TYPE_JWT = "jwt" + const val TOKEN_TYPE_REFRESH = "refresh_token" + const val ACCESS_TOKEN = "access_token" + const val CLIENT_ID = "client_id" const val EMAIL = "email" + const val HONOR_CODE = "honor_code" + const val NAME = "name" const val PASSWORD = "password" -} \ No newline at end of file + const val PROVIDER = "provider" + + const val AUTH_TYPE_GOOGLE = "google-oauth2" + const val AUTH_TYPE_FB = "facebook" + const val AUTH_TYPE_MICROSOFT = "azuread-oauth2" + + const val COURSE_KEY = "course_key" +} diff --git a/core/src/main/java/org/openedx/core/AppUpdateState.kt b/core/src/main/java/org/openedx/core/AppUpdateState.kt new file mode 100644 index 000000000..bf347cd29 --- /dev/null +++ b/core/src/main/java/org/openedx/core/AppUpdateState.kt @@ -0,0 +1,34 @@ +package org.openedx.core + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.mutableStateOf +import org.openedx.core.system.notifier.AppUpgradeEvent + +object AppUpdateState { + var wasUpdateDialogDisplayed = false + var wasUpdateDialogClosed = mutableStateOf(false) + + fun openPlayMarket(context: Context) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${context.packageName}"))) + } catch (e: ActivityNotFoundException) { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=${context.packageName}") + ) + ) + } + } + + data class AppUpgradeParameters( + val appUpgradeEvent: AppUpgradeEvent? = null, + val wasUpdateDialogClosed: Boolean = AppUpdateState.wasUpdateDialogClosed.value, + val appUpgradeRecommendedDialog: () -> Unit = {}, + val onAppUpgradeRecommendedBoxClick: () -> Unit = {}, + val onAppUpgradeRequired: () -> Unit = {}, + ) +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/BlockType.kt b/core/src/main/java/org/openedx/core/BlockType.kt index dc1862395..07a7bf882 100644 --- a/core/src/main/java/org/openedx/core/BlockType.kt +++ b/core/src/main/java/org/openedx/core/BlockType.kt @@ -14,7 +14,8 @@ enum class BlockType { SEQUENTIAL{ override fun isContainer() = true }, VERTICAL{ override fun isContainer() = true }, VIDEO{ override fun isContainer() = false }, - WORD_CLOUD{ override fun isContainer() = false }; + WORD_CLOUD{ override fun isContainer() = false }, + SURVEY{ override fun isContainer() = false }; abstract fun isContainer() : Boolean @@ -26,9 +27,24 @@ enum class BlockType { return try { BlockType.valueOf(actualType.uppercase()) } catch (e : Exception){ - BlockType.OTHERS + OTHERS } } + + fun sortByPriority(blockTypes: List): List { + val priorityMap = mapOf( + PROBLEM to 1, + VIDEO to 2, + DISCUSSION to 3, + HTML to 4 + ) + val comparator = Comparator { blockType1, blockType2 -> + val priority1 = priorityMap[blockType1] ?: Int.MAX_VALUE + val priority2 = priorityMap[blockType2] ?: Int.MAX_VALUE + priority1 - priority2 + } + return blockTypes.sortedWith(comparator) + } } } diff --git a/core/src/main/java/org/openedx/core/Validator.kt b/core/src/main/java/org/openedx/core/Validator.kt index ca758a071..cb3a66ae6 100644 --- a/core/src/main/java/org/openedx/core/Validator.kt +++ b/core/src/main/java/org/openedx/core/Validator.kt @@ -4,15 +4,19 @@ import java.util.regex.Pattern class Validator { - fun isEmailValid(email: String): Boolean { - val validEmailAddressRegex = - Pattern.compile("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", Pattern.CASE_INSENSITIVE) - val matcher = validEmailAddressRegex.matcher(email) - return matcher.find() + fun isEmailOrUserNameValid(input: String): Boolean { + return if (input.contains("@")) { + val validEmailAddressRegex = Pattern.compile( + "^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", Pattern.CASE_INSENSITIVE + ) + validEmailAddressRegex.matcher(input).find() + } else { + input.isNotBlank() && input.contains(" ").not() + } } fun isPasswordValid(password: String): Boolean { return password.length >= 2 } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/config/AgreementUrlsConfig.kt b/core/src/main/java/org/openedx/core/config/AgreementUrlsConfig.kt new file mode 100644 index 000000000..8532ba53a --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/AgreementUrlsConfig.kt @@ -0,0 +1,57 @@ +package org.openedx.core.config + +import android.net.Uri +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.Agreement +import org.openedx.core.domain.model.AgreementUrls + +internal data class AgreementUrlsConfig( + @SerializedName("PRIVACY_POLICY_URL") + private val privacyPolicyUrl: String = "", + @SerializedName("COOKIE_POLICY_URL") + private val cookiePolicyUrl: String = "", + @SerializedName("DATA_SELL_CONSENT_URL") + private val dataSellConsentUrl: String = "", + @SerializedName("TOS_URL") + private val tosUrl: String = "", + @SerializedName("EULA_URL") + private val eulaUrl: String = "", + @SerializedName("SUPPORTED_LANGUAGES") + private val supportedLanguages: List = emptyList(), +) { + fun mapToDomain(): Agreement { + val defaultAgreementUrls = AgreementUrls( + privacyPolicyUrl = privacyPolicyUrl, + cookiePolicyUrl = cookiePolicyUrl, + dataSellConsentUrl = dataSellConsentUrl, + tosUrl = tosUrl, + eulaUrl = eulaUrl, + supportedLanguages = supportedLanguages, + ) + val agreementUrls = if (supportedLanguages.isNotEmpty()) { + supportedLanguages.associateWith { + AgreementUrls( + privacyPolicyUrl = privacyPolicyUrl.appendLocale(it), + cookiePolicyUrl = cookiePolicyUrl.appendLocale(it), + dataSellConsentUrl = dataSellConsentUrl.appendLocale(it), + tosUrl = tosUrl.appendLocale(it), + eulaUrl = eulaUrl.appendLocale(it), + supportedLanguages = supportedLanguages, + ) + } + } else { + mapOf() + } + return Agreement(agreementUrls, defaultAgreementUrls) + } + + private fun String.appendLocale(locale: String): String { + if (this.isBlank()) return this + val uri = Uri.parse(this) + return Uri.Builder().scheme(uri.scheme) + .authority(uri.authority) + .appendPath(locale + uri.encodedPath) + .build() + .toString() + } +} diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt new file mode 100644 index 000000000..8739c4cfa --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -0,0 +1,167 @@ +package org.openedx.core.config + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.openedx.core.domain.model.AgreementUrls +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() + } + } + + fun getApiHostURL(): String { + return getString(API_HOST_URL, "") + } + + fun getUriScheme(): String { + return getString(URI_SCHEME, "") + } + + fun getOAuthClientId(): String { + return getString(OAUTH_CLIENT_ID, "") + } + + fun getAccessTokenType(): String { + return getString(TOKEN_TYPE, "") + } + + fun getFaqUrl(): String { + return getString(FAQ_URL, "") + } + + fun getFeedbackEmailAddress(): String { + return getString(FEEDBACK_EMAIL_ADDRESS, "") + } + + fun getAgreement(locale: String): AgreementUrls { + val agreement = + getObjectOrNewInstance(AGREEMENT_URLS, AgreementUrlsConfig::class.java).mapToDomain() + return agreement.getAgreementForLocale(locale) + } + + fun getFirebaseConfig(): FirebaseConfig { + return getObjectOrNewInstance(FIREBASE, FirebaseConfig::class.java) + } + + fun getFacebookConfig(): FacebookConfig { + return getObjectOrNewInstance(FACEBOOK, FacebookConfig::class.java) + } + + fun getGoogleConfig(): GoogleConfig { + return getObjectOrNewInstance(GOOGLE, GoogleConfig::class.java) + } + + fun getMicrosoftConfig(): MicrosoftConfig { + return getObjectOrNewInstance(MICROSOFT, MicrosoftConfig::class.java) + } + + fun isSocialAuthEnabled() = getBoolean(SOCIAL_AUTH_ENABLED, false) + + fun getDiscoveryConfig(): DiscoveryConfig { + return getObjectOrNewInstance(DISCOVERY, DiscoveryConfig::class.java) + } + + fun getProgramConfig(): ProgramConfig { + return getObjectOrNewInstance(PROGRAM, ProgramConfig::class.java) + } + + fun isWhatsNewEnabled(): Boolean { + return getBoolean(WHATS_NEW_ENABLED, false) + } + + fun isPreLoginExperienceEnabled(): Boolean { + return getBoolean(PRE_LOGIN_EXPERIENCE_ENABLED, true) + } + + fun isCourseNestedListEnabled(): Boolean { + return getBoolean(COURSE_NESTED_LIST_ENABLED, false) + } + + fun isCourseBannerEnabled(): Boolean { + return getBoolean(COURSE_BANNER_ENABLED, true) + } + + fun isCourseTopTabBarEnabled(): Boolean { + return getBoolean(COURSE_TOP_TAB_BAR_ENABLED, false) + } + + fun isCourseUnitProgressEnabled(): Boolean { + return getBoolean(COURSE_UNIT_PROGRESS_ENABLED, false) + } + + private fun getString(key: String, defaultValue: String): String { + val element = getObject(key) + return if (element != null) { + element.asString + } else { + defaultValue + } + } + + private fun getBoolean(key: String, defaultValue: Boolean): Boolean { + val element = getObject(key) + return element?.asBoolean ?: defaultValue + } + + private fun getObjectOrNewInstance(key: String, cls: Class): T { + val element = getObject(key) + return if (element != null) { + val gson = Gson() + gson.fromJson(element, cls) + } else { + try { + cls.getDeclaredConstructor().newInstance() + } catch (e: InstantiationException) { + throw RuntimeException(e) + } catch (e: IllegalAccessException) { + throw RuntimeException(e) + } + } + } + + private fun getObject(key: String): JsonElement? { + return configProperties.get(key) + } + + companion object { + private const val API_HOST_URL = "API_HOST_URL" + private const val URI_SCHEME = "URI_SCHEME" + private const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" + private const val TOKEN_TYPE = "TOKEN_TYPE" + private const val FAQ_URL = "FAQ_URL" + private const val FEEDBACK_EMAIL_ADDRESS = "FEEDBACK_EMAIL_ADDRESS" + private const val AGREEMENT_URLS = "AGREEMENT_URLS" + private const val WHATS_NEW_ENABLED = "WHATS_NEW_ENABLED" + private const val SOCIAL_AUTH_ENABLED = "SOCIAL_AUTH_ENABLED" + private const val FIREBASE = "FIREBASE" + private const val FACEBOOK = "FACEBOOK" + private const val GOOGLE = "GOOGLE" + private const val MICROSOFT = "MICROSOFT" + private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" + private const val DISCOVERY = "DISCOVERY" + private const val PROGRAM = "PROGRAM" + private const val COURSE_NESTED_LIST_ENABLED = "COURSE_NESTED_LIST_ENABLED" + private const val COURSE_BANNER_ENABLED = "COURSE_BANNER_ENABLED" + private const val COURSE_TOP_TAB_BAR_ENABLED = "COURSE_TOP_TAB_BAR_ENABLED" + private const val COURSE_UNIT_PROGRESS_ENABLED = "COURSE_UNIT_PROGRESS_ENABLED" + } + + enum class ViewType { + NATIVE, + WEBVIEW + } +} diff --git a/core/src/main/java/org/openedx/core/config/DiscoveryConfig.kt b/core/src/main/java/org/openedx/core/config/DiscoveryConfig.kt new file mode 100644 index 000000000..f94a9d753 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/DiscoveryConfig.kt @@ -0,0 +1,27 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class DiscoveryConfig( + @SerializedName("TYPE") + private val viewType: String = Config.ViewType.NATIVE.name, + + @SerializedName("WEBVIEW") + val webViewConfig: DiscoveryWebViewConfig = DiscoveryWebViewConfig(), +) { + + fun isViewTypeWebView(): Boolean { + return Config.ViewType.WEBVIEW.name.equals(viewType, ignoreCase = true) + } +} + +data class DiscoveryWebViewConfig( + @SerializedName("BASE_URL") + val baseUrl: String = "", + + @SerializedName("COURSE_DETAIL_TEMPLATE") + val courseUrlTemplate: String = "", + + @SerializedName("PROGRAM_DETAIL_TEMPLATE") + val programUrlTemplate: String = "", +) diff --git a/core/src/main/java/org/openedx/core/config/FacebookConfig.kt b/core/src/main/java/org/openedx/core/config/FacebookConfig.kt new file mode 100644 index 000000000..f000aacea --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/FacebookConfig.kt @@ -0,0 +1,14 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class FacebookConfig( + @SerializedName("ENABLED") + private val enabled: Boolean = false, + @SerializedName("FACEBOOK_APP_ID") + val appId: String = "", + @SerializedName("CLIENT_TOKEN") + val clientToken: String = "", +) { + fun isEnabled() = enabled && appId.isNotBlank() && clientToken.isNotBlank() +} diff --git a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt new file mode 100644 index 000000000..b003c3230 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt @@ -0,0 +1,20 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class FirebaseConfig( + @SerializedName("ENABLED") + val enabled: Boolean = false, + + @SerializedName("PROJECT_ID") + val projectId: String = "", + + @SerializedName("APPLICATION_ID") + val applicationId: String = "", + + @SerializedName("API_KEY") + val apiKey: String = "", + + @SerializedName("GCM_SENDER_ID") + val gcmSenderId: String = "", +) diff --git a/core/src/main/java/org/openedx/core/config/GoogleConfig.kt b/core/src/main/java/org/openedx/core/config/GoogleConfig.kt new file mode 100644 index 000000000..5db7baffb --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/GoogleConfig.kt @@ -0,0 +1,12 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class GoogleConfig( + @SerializedName("ENABLED") + private val enabled: Boolean = false, + @SerializedName("CLIENT_ID") + val clientId: String = "", +) { + fun isEnabled() = enabled && clientId.isNotBlank() +} diff --git a/core/src/main/java/org/openedx/core/config/MicrosoftConfig.kt b/core/src/main/java/org/openedx/core/config/MicrosoftConfig.kt new file mode 100644 index 000000000..e24953f98 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/MicrosoftConfig.kt @@ -0,0 +1,14 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class MicrosoftConfig( + @SerializedName("ENABLED") + private val enabled: Boolean = false, + @SerializedName("CLIENT_ID") + val clientId: String = "", + @SerializedName("PACKAGE_SIGNATURE") + val packageSignature: String = "", +) { + fun isEnabled() = enabled && clientId.isNotBlank() && packageSignature.isNotBlank() +} diff --git a/core/src/main/java/org/openedx/core/config/ProgramConfig.kt b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt new file mode 100644 index 000000000..55714dadc --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/ProgramConfig.kt @@ -0,0 +1,21 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class ProgramConfig( + @SerializedName("TYPE") + private val viewType: String = Config.ViewType.NATIVE.name, + @SerializedName("WEBVIEW") + val webViewConfig: ProgramWebViewConfig = ProgramWebViewConfig(), +){ + fun isViewTypeWebView(): Boolean { + return Config.ViewType.WEBVIEW.name.equals(viewType, ignoreCase = true) + } +} + +data class ProgramWebViewConfig( + @SerializedName("PROGRAM_URL") + val programUrl: String = "", + @SerializedName("PROGRAM_DETAIL_URL_TEMPLATE") + val programDetailUrlTemplate: String = "", +) 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 89538252d..64e749a26 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 @@ -1,24 +1,25 @@ package org.openedx.core.data.api -import org.openedx.core.data.model.* import okhttp3.ResponseBody +import org.openedx.core.data.model.* import retrofit2.http.* interface CourseApi { - @GET("/mobile_api_extensions/v1/users/{username}/course_enrollments") + @GET("/api/mobile/v3/users/{username}/course_enrollments/") suspend fun getEnrolledCourses( @Header("Cache-Control") cacheControlHeaderParam: String? = null, @Path("username") username: String, @Query("org") org: String? = null, @Query("page") page: Int - ): DashboardCourseList + ): CourseEnrollments - @GET("/mobile_api_extensions/courses/v1/courses/") + @GET("/api/courses/v1/courses/") suspend fun getCourseList( @Query("search_term") searchQuery: String? = null, @Query("page") page: Int, @Query("mobile") mobile: Boolean, + @Query("mobile_search") mobileSearch: Boolean, @Query("username") username: String? = null, @Query("org") org: String? = null, @Query("permissions") permission: List = listOf( @@ -28,15 +29,14 @@ interface CourseApi { ) ): CourseList - @GET("/mobile_api_extensions/v1/courses/{course_id}") + @GET("/api/courses/v1/courses/{course_id}") suspend fun getCourseDetail( @Path("course_id") courseId: String?, - @Query("username") username: String? = null, - @Query("is_enrolled") isEnrolled: Boolean = true, + @Query("username") username: String? = null ): CourseDetails @GET( - "/mobile_api_extensions/{api_version}/blocks/?" + + "/api/mobile/{api_version}/course_info/blocks/?" + "depth=all&" + "requested_fields=contains_gated_content,show_gated_sections,special_exam_info,graded,format,student_view_multi_device,due,completion&" + "student_view_data=video,discussion&" + @@ -65,6 +65,15 @@ interface CourseApi { blocksCompletionBody: BlocksCompletionBody ) + @GET("/api/course_home/v1/dates/{course_id}") + suspend fun getCourseDates(@Path("course_id") courseId: String): CourseDates + + @POST("/api/course_experience/v1/reset_course_deadlines") + suspend fun resetCourseDates(@Body courseBody: Map): ResetCourseDates + + @GET("/api/course_experience/v1/course_deadlines_info/{course_id}") + suspend fun getDatesBannerInfo(@Path("course_id") courseId: String): CourseDatesBannerInfo + @GET("/api/mobile/v1/course_info/{course_id}/handouts") suspend fun getHandouts(@Path("course_id") courseId: String): HandoutsModel 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 4ddc549a0..9c07367ac 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 @@ -30,23 +30,38 @@ data class Block( @SerializedName("block_counts") val blockCounts: BlockCounts?, @SerializedName("completion") - val completion: Double? + val completion: Double?, + @SerializedName("contains_gated_content") + val containsGatedContent: Boolean?, ) { - fun mapToDomain(): Block { + fun mapToDomain(blockData: Map): Block { + val blockType = BlockType.getBlockType(type ?: "") + val descendantsType = if (blockType == BlockType.VERTICAL) { + val types = descendants?.map { descendant -> + BlockType.getBlockType(blockData[descendant]?.type ?: "") + } ?: emptyList() + val sortedBlockTypes = BlockType.sortByPriority(types) + sortedBlockTypes.firstOrNull() ?: blockType + } else { + blockType + } + return org.openedx.core.domain.model.Block( id = id ?: "", blockId = blockId ?: "", lmsWebUrl = lmsWebUrl ?: "", legacyWebUrl = legacyWebUrl ?: "", studentViewUrl = studentViewUrl ?: "", - type = BlockType.getBlockType(type ?: ""), + type = blockType, displayName = displayName ?: "", descendants = descendants ?: emptyList(), + descendantsType = descendantsType, graded = graded ?: false, studentViewData = studentViewData?.mapToDomain(), studentViewMultiDevice = studentViewMultiDevice ?: false, blockCounts = blockCounts?.mapToDomain()!!, - completion = completion ?: 0.0 + completion = completion ?: 0.0, + containsGatedContent = containsGatedContent ?: false ) } } 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 new file mode 100644 index 000000000..887112845 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt @@ -0,0 +1,28 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import java.util.* + +data class CourseDateBlock( + @SerializedName("complete") + val complete: Boolean = false, + @SerializedName("date") + val date: String = "", // ISO 8601 compliant format + @SerializedName("assignment_type") + val assignmentType: String? = "", + @SerializedName("date_type") + val dateType: DateType = DateType.NONE, + @SerializedName("description") + val description: String = "", + @SerializedName("learner_has_access") + val learnerHasAccess: Boolean = false, + @SerializedName("link") + val link: String = "", + @SerializedName("link_text") + val linkText: String = "", + @SerializedName("title") + val title: String = "", + // component blockId in-case of navigating inside the app for component available in mobile + @SerializedName("first_component_block_id") + val blockId: String = "", +) diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDates.kt b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt new file mode 100644 index 000000000..97fc3180f --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt @@ -0,0 +1,92 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.DatesSection +import org.openedx.core.utils.TimeUtils +import org.openedx.core.utils.addDays +import org.openedx.core.utils.clearTime +import org.openedx.core.utils.isToday +import java.util.Date +import org.openedx.core.domain.model.CourseDateBlock as DomainCourseDateBlock + +data class CourseDates( + @SerializedName("course_date_blocks") + val courseDateBlocks: List, + @SerializedName("dates_banner_info") + val datesBannerInfo: DatesBannerInfo?, + @SerializedName("has_ended") + val hasEnded: Boolean?, +) { + fun getCourseDatesResult(): CourseDatesResult { + return CourseDatesResult( + datesSection = getStructuredCourseDates(), + courseBanner = getDatesBannerInfo(), + ) + } + + private fun getDatesBannerInfo(): CourseDatesBannerInfo { + return CourseDatesBannerInfo( + missedDeadlines = datesBannerInfo?.missedDeadlines ?: false, + missedGatedContent = datesBannerInfo?.missedGatedContent ?: false, + verifiedUpgradeLink = datesBannerInfo?.verifiedUpgradeLink ?: "", + contentTypeGatingEnabled = datesBannerInfo?.contentTypeGatingEnabled ?: false, + hasEnded = hasEnded ?: false, + ) + } + + private fun getStructuredCourseDates(): LinkedHashMap> { + val currentDate = Date() + val courseDatesResponse: LinkedHashMap> = + LinkedHashMap() + val datesList = mapToDomain() + // Added dates for completed, past due, today, this week, next week and upcoming + courseDatesResponse[DatesSection.COMPLETED] = + datesList.filter { it.isCompleted() }.also { datesList.removeAll(it) } + + courseDatesResponse[DatesSection.PAST_DUE] = + datesList.filter { currentDate.after(it.date) }.also { datesList.removeAll(it) } + + courseDatesResponse[DatesSection.TODAY] = + datesList.filter { it.date.isToday() }.also { datesList.removeAll(it) } + + //Update the date for upcoming comparison without time + currentDate.clearTime() + + // for current week except today + courseDatesResponse[DatesSection.THIS_WEEK] = datesList.filter { + it.date.after(currentDate) && it.date.before(currentDate.addDays(8)) + }.also { datesList.removeAll(it) } + + // for coming week + courseDatesResponse[DatesSection.NEXT_WEEK] = datesList.filter { + it.date.after(currentDate.addDays(7)) && it.date.before(currentDate.addDays(15)) + }.also { datesList.removeAll(it) } + + // for upcoming + courseDatesResponse[DatesSection.UPCOMING] = datesList.filter { + it.date.after(currentDate.addDays(14)) + }.also { datesList.removeAll(it) } + + return courseDatesResponse + } + + private fun mapToDomain(): MutableList { + return courseDateBlocks.mapNotNull { item -> + TimeUtils.iso8601ToDate(item.date)?.let { date -> + DomainCourseDateBlock( + title = item.title, + description = item.description, + link = item.link, + blockId = item.blockId, + date = date, + complete = item.complete, + learnerHasAccess = item.learnerHasAccess, + dateType = item.dateType, + assignmentType = item.assignmentType + ) + } + }.sortedBy { it.date }.toMutableList() + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt b/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt new file mode 100644 index 000000000..09e2d39cb --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt @@ -0,0 +1,21 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseDatesBannerInfo + +data class CourseDatesBannerInfo( + @SerializedName("dates_banner_info") + val datesBannerInfo: DatesBannerInfo?, + @SerializedName("has_ended") + val hasEnded: Boolean?, +) { + fun mapToDomain(): CourseDatesBannerInfo { + return CourseDatesBannerInfo( + missedDeadlines = datesBannerInfo?.missedDeadlines ?: false, + missedGatedContent = datesBannerInfo?.missedGatedContent ?: false, + verifiedUpgradeLink = datesBannerInfo?.verifiedUpgradeLink ?: "", + contentTypeGatingEnabled = datesBannerInfo?.contentTypeGatingEnabled ?: false, + hasEnded = hasEnded ?: false, + ) + } +} 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 new file mode 100644 index 000000000..1c10cfa92 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt @@ -0,0 +1,8 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +data class CourseEnrollments( + @SerializedName("enrollments") + val enrollments: DashboardCourseList +) 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 ac2552c26..9f22a14a0 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 @@ -41,7 +41,7 @@ data class CourseStructureModel( return CourseStructure( root = root, blockData = blockData.map { - it.value.mapToDomain() + it.value.mapToDomain(blockData) }, id = id ?: "", name = name ?: "", @@ -51,7 +51,7 @@ data class CourseStructureModel( startDisplay = startDisplay ?: "", startType = startType ?: "", end = TimeUtils.iso8601ToDate(end ?: ""), - coursewareAccess = coursewareAccess?.mapToDomain()!!, + coursewareAccess = coursewareAccess?.mapToDomain(), media = media?.mapToDomain(), certificate = certificate?.mapToDomain(), isSelfPaced = isSelfPaced ?: false @@ -70,7 +70,7 @@ data class CourseStructureModel( startDisplay = startDisplay ?: "", startType = startType ?: "", end = end ?: "", - coursewareAccess = coursewareAccess?.mapToRoomEntity()!!, + coursewareAccess = coursewareAccess?.mapToRoomEntity(), media = MediaDb.createFrom(media), certificate = certificate?.mapToRoomEntity(), isSelfPaced = isSelfPaced ?: false diff --git a/core/src/main/java/org/openedx/core/data/model/DateType.kt b/core/src/main/java/org/openedx/core/data/model/DateType.kt new file mode 100644 index 000000000..8604286d7 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/DateType.kt @@ -0,0 +1,32 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.R + +enum class DateType(val drawableResId: Int? = null) { + @SerializedName("todays-date") + TODAY_DATE(R.drawable.core_ic_calendar), + + @SerializedName("course-start-date") + COURSE_START_DATE(R.drawable.core_ic_start_end), + + @SerializedName("course-end-date") + COURSE_END_DATE(R.drawable.core_ic_start_end), + + @SerializedName("course-expired-date") + COURSE_EXPIRED_DATE(R.drawable.core_ic_course_expire), + + @SerializedName("assignment-due-date") + ASSIGNMENT_DUE_DATE(R.drawable.core_ic_assignment), + + @SerializedName("certificate-available-date") + CERTIFICATE_AVAILABLE_DATE(R.drawable.core_ic_certificate), + + @SerializedName("verified-upgrade-deadline") + VERIFIED_UPGRADE_DEADLINE(R.drawable.core_ic_calendar), + + @SerializedName("verification-deadline-date") + VERIFICATION_DEADLINE_DATE(R.drawable.core_ic_calendar), + + NONE, +} diff --git a/core/src/main/java/org/openedx/core/data/model/DatesBannerInfo.kt b/core/src/main/java/org/openedx/core/data/model/DatesBannerInfo.kt new file mode 100644 index 000000000..639c5313b --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/DatesBannerInfo.kt @@ -0,0 +1,14 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +data class DatesBannerInfo( + @SerializedName("missed_deadlines") + val missedDeadlines: Boolean = false, + @SerializedName("missed_gated_content") + val missedGatedContent: Boolean = false, + @SerializedName("verified_upgrade_link") + val verifiedUpgradeLink: String? = "", + @SerializedName("content_type_gating_enabled") + val contentTypeGatingEnabled: Boolean = false, +) 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 74aba5073..4afc9ef71 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 @@ -61,7 +61,7 @@ data class EnrolledCourseData( end = TimeUtils.iso8601ToDate(end ?: ""), dynamicUpgradeDeadline = dynamicUpgradeDeadline ?: "", subscriptionId = subscriptionId ?: "", - coursewareAccess = coursewareAccess?.mapToDomain()!!, + coursewareAccess = coursewareAccess?.mapToDomain(), media = media?.mapToDomain(), courseImage = courseImage ?: "", courseAbout = courseAbout ?: "", @@ -86,7 +86,7 @@ data class EnrolledCourseData( end = end ?: "", dynamicUpgradeDeadline = dynamicUpgradeDeadline ?: "", subscriptionId = subscriptionId ?: "", - coursewareAccess = coursewareAccess?.mapToRoomEntity()!!, + coursewareAccess = coursewareAccess?.mapToRoomEntity(), media = MediaDb.createFrom(media), courseImage = courseImage ?: "", courseAbout = courseAbout ?: "", diff --git a/core/src/main/java/org/openedx/core/data/model/ResetCourseDates.kt b/core/src/main/java/org/openedx/core/data/model/ResetCourseDates.kt new file mode 100644 index 000000000..5cf00b0d3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/ResetCourseDates.kt @@ -0,0 +1,27 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.ResetCourseDates + +data class ResetCourseDates( + @SerializedName("message") + val message: String = "", + @SerializedName("body") + val body: String = "", + @SerializedName("header") + val header: String = "", + @SerializedName("link") + val link: String = "", + @SerializedName("link_text") + val linkText: String = "", +) { + fun mapToDomain(): ResetCourseDates { + return ResetCourseDates( + message = message, + body = body, + header = header, + link = link, + linkText = linkText, + ) + } +} 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 26d83e81e..b1e9a53cf 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 @@ -31,9 +31,22 @@ data class BlockDb( @ColumnInfo("descendants") val descendants: List, @ColumnInfo("completion") - val completion: Double + val completion: Double, + @ColumnInfo("contains_gated_content") + val containsGatedContent: Boolean ) { - fun mapToDomain(): Block { + fun mapToDomain(blocks: List): Block { + val blockType = BlockType.getBlockType(type) + val descendantsType = if (blockType == BlockType.VERTICAL) { + val types = descendants.map { descendant -> + BlockType.getBlockType(blocks.find { it.id == descendant }?.type ?: "") + } + val sortedBlockTypes = BlockType.sortByPriority(types) + sortedBlockTypes.firstOrNull() ?: blockType + } else { + blockType + } + return Block( id = id, blockId = blockId, @@ -47,7 +60,9 @@ data class BlockDb( studentViewMultiDevice = studentViewMultiDevice, blockCounts = blockCounts.mapToDomain(), descendants = descendants, + descendantsType = descendantsType, completion = completion, + containsGatedContent = containsGatedContent ) } @@ -70,7 +85,8 @@ data class BlockDb( studentViewData = StudentViewDataDb.createFrom(studentViewData), studentViewMultiDevice = studentViewMultiDevice ?: false, blockCounts = BlockCountsDb.createFrom(blockCounts), - completion = completion ?: 0.0 + completion = completion ?: 0.0, + containsGatedContent = containsGatedContent ?: false ) } } 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 3c0ac045b..90352d821 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 @@ -33,7 +33,7 @@ data class CourseStructureEntity( @ColumnInfo("end") val end: String?, @Embedded - val coursewareAccess: CoursewareAccessDb, + val coursewareAccess: CoursewareAccessDb?, @Embedded val media: MediaDb?, @Embedded @@ -45,7 +45,7 @@ data class CourseStructureEntity( fun mapToDomain(): CourseStructure { return CourseStructure( root, - blocks.map { it.mapToDomain() }, + blocks.map { it.mapToDomain(blocks) }, id, name, number, @@ -54,7 +54,7 @@ data class CourseStructureEntity( startDisplay, startType, TimeUtils.iso8601ToDate(end ?: ""), - coursewareAccess.mapToDomain(), + coursewareAccess?.mapToDomain(), media?.mapToDomain(), certificate?.mapToDomain(), isSelfPaced 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 31eaf463f..05aab3bdd 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 @@ -61,7 +61,7 @@ data class EnrolledCourseDataDb( @ColumnInfo("subscriptionId") val subscriptionId: String, @Embedded - val coursewareAccess: CoursewareAccessDb, + val coursewareAccess: CoursewareAccessDb?, @Embedded val media: MediaDb?, @ColumnInfo(name = "course_image_link") @@ -93,7 +93,7 @@ data class EnrolledCourseDataDb( TimeUtils.iso8601ToDate(end), dynamicUpgradeDeadline, subscriptionId, - coursewareAccess.mapToDomain(), + coursewareAccess?.mapToDomain(), media?.mapToDomain(), courseImage, courseAbout, 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 a3eb44d06..11f21c661 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 @@ -1,13 +1,14 @@ package org.openedx.core.data.storage -import org.openedx.core.domain.model.User +import org.openedx.core.data.model.User import org.openedx.core.domain.model.VideoSettings interface CorePreferences { var accessToken: String var refreshToken: String + var accessTokenExpiresAt: Long var user: User? var videoSettings: VideoSettings fun clear() -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/data/storage/InAppReviewPreferences.kt b/core/src/main/java/org/openedx/core/data/storage/InAppReviewPreferences.kt new file mode 100644 index 000000000..c9bf0638c --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/storage/InAppReviewPreferences.kt @@ -0,0 +1,28 @@ +package org.openedx.core.data.storage + +interface InAppReviewPreferences { + var lastReviewVersion: VersionName + var wasPositiveRated: Boolean + + fun setVersion(version: String) { + lastReviewVersion = formatVersionName(version) + } + + fun formatVersionName(version: String) = version + .split(".") + .let { + VersionName( + majorVersion = it[0].toInt(), + minorVersion = it[1].toInt() + ) + } + + data class VersionName( + var majorVersion: Int, + var minorVersion: Int + ) { + companion object { + val default = VersionName(Int.MIN_VALUE, Int.MIN_VALUE) + } + } +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/domain/model/AgreementUrls.kt b/core/src/main/java/org/openedx/core/domain/model/AgreementUrls.kt new file mode 100644 index 000000000..a6d3e4e66 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/AgreementUrls.kt @@ -0,0 +1,25 @@ +package org.openedx.core.domain.model + +/** + * Data class with information about user agreements URLs + * + * @param agreementUrls Map with keys from SUPPORTED_LANGUAGES config + * @param defaultAgreementUrls AgreementUrls for default language ('en') + */ +internal data class Agreement( + private val agreementUrls: Map = mapOf(), + private val defaultAgreementUrls: AgreementUrls +) { + fun getAgreementForLocale(locale: String): AgreementUrls { + return agreementUrls.getOrDefault(locale, defaultAgreementUrls) + } +} + +data class AgreementUrls( + val privacyPolicyUrl: String = "", + val cookiePolicyUrl: String = "", + val dataSellConsentUrl: String = "", + val tosUrl: String = "", + val eulaUrl: String = "", + val supportedLanguages: List = emptyList(), +) 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 4e384ff5b..1c69142a8 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 @@ -22,7 +22,9 @@ data class Block( val studentViewMultiDevice: Boolean, val blockCounts: BlockCounts, val descendants: List, + val descendantsType: BlockType, val completion: Double, + val containsGatedContent: Boolean = false, val downloadModel: DownloadModel? = null ) { val isDownloadable: Boolean @@ -35,6 +37,7 @@ data class Block( BlockType.VIDEO -> { FileType.VIDEO } + else -> { FileType.UNKNOWN } @@ -47,6 +50,40 @@ data class Block( fun isDownloaded() = downloadModel?.downloadedState == DownloadedState.DOWNLOADED + fun isGated() = containsGatedContent + + fun isCompleted() = completion == 1.0 + + fun getFirstDescendantBlock(blocks: List): Block? { + if (blocks.isEmpty()) return null + descendants.forEach { descendant -> + blocks.find { it.id == descendant }?.let { descendantBlock -> + return descendantBlock + } + } + return null + } + + fun getDownloadsCount(blocks: List): Int { + if (blocks.isEmpty()) return 0 + var count = 0 + descendants.forEach { id -> + blocks.find { it.id == id }?.let { descendantBlock -> + count += blocks.filter { descendantBlock.descendants.contains(it.id) && it.isDownloadable }.size + } + } + return count + } + + val isVideoBlock get() = type == BlockType.VIDEO + val isDiscussionBlock get() = type == BlockType.DISCUSSION + val isHTMLBlock get() = type == BlockType.HTML + val isProblemBlock get() = type == BlockType.PROBLEM + val isOpenAssessmentBlock get() = type == BlockType.OPENASSESSMENT + val isDragAndDropBlock get() = type == BlockType.DRAG_AND_DROP_V2 + val isWordCloudBlock get() = type == BlockType.WORD_CLOUD + val isLTIConsumerBlock get() = type == BlockType.LTI_CONSUMER + val isSurveyBlock get() = type == BlockType.SURVEY } data class StudentViewData( @@ -54,7 +91,7 @@ data class StudentViewData( val duration: Any, val transcripts: HashMap?, val encodedVideos: EncodedVideos?, - val topicId: String + val topicId: String, ) data class EncodedVideos( @@ -63,7 +100,7 @@ data class EncodedVideos( var fallback: VideoInfo?, var desktopMp4: VideoInfo?, var mobileHigh: VideoInfo?, - var mobileLow: VideoInfo? + var mobileLow: VideoInfo?, ) { val hasDownloadableVideo: Boolean get() = isPreferredVideoInfo(hls) || @@ -72,6 +109,21 @@ data class EncodedVideos( isPreferredVideoInfo(mobileHigh) || isPreferredVideoInfo(mobileLow) + val hasNonYoutubeVideo: Boolean + get() = mobileHigh?.url != null + || mobileLow?.url != null + || desktopMp4?.url != null + || hls?.url != null + || fallback?.url != null + + val videoUrl: String + get() = mobileHigh?.url + ?: mobileLow?.url + ?: desktopMp4?.url + ?: hls?.url + ?: fallback?.url + ?: "" + fun getPreferredVideoInfoForDownloading(preferredVideoQuality: VideoQuality): VideoInfo? { var preferredVideoInfo = when (preferredVideoQuality) { VideoQuality.OPTION_360P -> mobileLow @@ -125,9 +177,9 @@ data class EncodedVideos( data class VideoInfo( val url: String, - val fileSize: Int + val fileSize: Int, ) data class BlockCounts( - val video: Int + val video: Int, ) 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 new file mode 100644 index 000000000..7e91c59fa --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt @@ -0,0 +1,32 @@ +package org.openedx.core.domain.model + +import org.openedx.core.data.model.DateType +import org.openedx.core.utils.isTimeLessThan24Hours +import org.openedx.core.utils.isToday +import java.util.Date + +data class CourseDateBlock( + val title: String = "", + val description: String = "", + val link: String = "", + val blockId: String = "", + val learnerHasAccess: Boolean = false, + val complete: Boolean = false, + val date: Date, + val dateType: DateType = DateType.NONE, + val assignmentType: String? = "", +) { + fun isCompleted(): Boolean { + return complete || (dateType in setOf( + DateType.COURSE_START_DATE, + DateType.COURSE_END_DATE, + DateType.CERTIFICATE_AVAILABLE_DATE, + DateType.VERIFIED_UPGRADE_DEADLINE, + DateType.VERIFICATION_DEADLINE_DATE, + ) && date.before(Date())) + } + + fun isTimeDifferenceLessThan24Hours(): Boolean { + return (date.isToday() && date.before(Date())) || date.isTimeLessThan24Hours() + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDatesBannerInfo.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDatesBannerInfo.kt new file mode 100644 index 000000000..3281ca045 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesBannerInfo.kt @@ -0,0 +1,65 @@ +package org.openedx.core.domain.model + +import org.openedx.core.R +import org.openedx.core.domain.model.CourseBannerType.BLANK +import org.openedx.core.domain.model.CourseBannerType.INFO_BANNER +import org.openedx.core.domain.model.CourseBannerType.RESET_DATES +import org.openedx.core.domain.model.CourseBannerType.UPGRADE_TO_GRADED +import org.openedx.core.domain.model.CourseBannerType.UPGRADE_TO_RESET + +data class CourseDatesBannerInfo( + private val missedDeadlines: Boolean, + private val missedGatedContent: Boolean, + private val verifiedUpgradeLink: String, + private val contentTypeGatingEnabled: Boolean, + private val hasEnded: Boolean, +) { + val bannerType by lazy { getCourseBannerType() } + + fun isBannerAvailableForUserType(isSelfPaced: Boolean): Boolean { + if (hasEnded) return false + + val selfPacedAvailable = isSelfPaced && bannerType != BLANK + val instructorPacedAvailable = !isSelfPaced && bannerType == UPGRADE_TO_GRADED + + return selfPacedAvailable || instructorPacedAvailable + } + + fun isBannerAvailableForDashboard(): Boolean { + return hasEnded.not() && bannerType == RESET_DATES + } + + private fun getCourseBannerType(): CourseBannerType = when { + canUpgradeToGraded() -> UPGRADE_TO_GRADED + canUpgradeToReset() -> UPGRADE_TO_RESET + canResetDates() -> RESET_DATES + infoBanner() -> INFO_BANNER + else -> BLANK + } + + private fun infoBanner(): Boolean = !missedDeadlines + + private fun canUpgradeToGraded(): Boolean = contentTypeGatingEnabled && !missedDeadlines + + private fun canUpgradeToReset(): Boolean = + !canUpgradeToGraded() && missedDeadlines && missedGatedContent + + private fun canResetDates(): Boolean = + !canUpgradeToGraded() && missedDeadlines && !missedGatedContent +} + +enum class CourseBannerType( + val headerResId: Int = 0, + val bodyResId: Int = 0, + val buttonResId: Int = 0 +) { + BLANK, + INFO_BANNER(bodyResId = R.string.core_dates_info_banner_body), + UPGRADE_TO_GRADED(bodyResId = R.string.core_dates_upgrade_to_graded_banner_body), + UPGRADE_TO_RESET(bodyResId = R.string.core_dates_upgrade_to_reset_banner_body), + RESET_DATES( + headerResId = R.string.core_dates_reset_dates_banner_header, + bodyResId = R.string.core_dates_reset_dates_banner_body, + buttonResId = R.string.core_dates_reset_dates_banner_button + ); +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDatesResult.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResult.kt new file mode 100644 index 000000000..2431bc7db --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResult.kt @@ -0,0 +1,6 @@ +package org.openedx.core.domain.model + +data class CourseDatesResult( + val datesSection: LinkedHashMap>, + val courseBanner: CourseDatesBannerInfo, +) 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 8ac5b6e41..bdb3820de 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,6 +1,6 @@ package org.openedx.core.domain.model -import java.util.* +import java.util.Date data class CourseStructure( val root: String, @@ -13,8 +13,8 @@ data class CourseStructure( val startDisplay: String, val startType: String, val end: Date?, - val coursewareAccess: CoursewareAccess, + val coursewareAccess: CoursewareAccess?, val media: Media?, val certificate: Certificate?, val isSelfPaced: Boolean -) \ No newline at end of file +) diff --git a/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt b/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt new file mode 100644 index 000000000..111d6e65e --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt @@ -0,0 +1,13 @@ +package org.openedx.core.domain.model + +import org.openedx.core.R + +enum class DatesSection(val stringResId: Int) { + COMPLETED(R.string.core_date_type_completed), + PAST_DUE(R.string.core_date_type_past_due), + TODAY(R.string.core_date_type_today), + THIS_WEEK(R.string.core_date_type_this_week), + NEXT_WEEK(R.string.core_date_type_next_week), + UPCOMING(R.string.core_date_type_upcoming), + NONE(R.string.core_date_type_none); +} 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 f5bb23d41..2a66cccde 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 @@ -16,7 +16,7 @@ data class EnrolledCourseData( val end: Date?, val dynamicUpgradeDeadline: String, val subscriptionId: String, - val coursewareAccess: CoursewareAccess, + val coursewareAccess: CoursewareAccess?, val media: Media?, val courseImage: String, val courseAbout: String, diff --git a/core/src/main/java/org/openedx/core/domain/model/ResetCourseDates.kt b/core/src/main/java/org/openedx/core/domain/model/ResetCourseDates.kt new file mode 100644 index 000000000..60eab790c --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/ResetCourseDates.kt @@ -0,0 +1,9 @@ +package org.openedx.core.domain.model + +data class ResetCourseDates( + val message: String, + val body: String, + val header: String, + val link: String, + val linkText: String, +) diff --git a/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt b/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt index 9e2b84ddb..07241824b 100644 --- a/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt +++ b/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt @@ -11,11 +11,11 @@ data class VideoSettings( } } -enum class VideoQuality(val titleResId: Int) { - AUTO(R.string.auto_recommended_text), - OPTION_360P(R.string.video_quality_p360), - OPTION_540P(R.string.video_quality_p540), - OPTION_720P(R.string.video_quality_p720); +enum class VideoQuality(val titleResId: Int, val width: Int, val height: Int) { + AUTO(R.string.auto_recommended_text, 0, 0), + OPTION_360P(R.string.video_quality_p360, 640, 360), + OPTION_540P(R.string.video_quality_p540, 960, 540), + OPTION_720P(R.string.video_quality_p720, 1280, 720); val value: String = this.name.replace("OPTION_", "").lowercase() } diff --git a/core/src/main/java/org/openedx/core/extension/AssetExt.kt b/core/src/main/java/org/openedx/core/extension/AssetExt.kt new file mode 100644 index 000000000..190f68721 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/AssetExt.kt @@ -0,0 +1,15 @@ +package org.openedx.core.extension + +import android.content.res.AssetManager +import android.util.Log +import java.io.BufferedReader + +fun AssetManager.readAsText(fileName: String): String? { + return try { + open(fileName).bufferedReader().use(BufferedReader::readText) + } catch (e: Exception) { + Log.e("AssetExt", "Unable to load file $fileName from assets") + e.printStackTrace() + null + } +} diff --git a/core/src/main/java/org/openedx/core/extension/ContinuationExt.kt b/core/src/main/java/org/openedx/core/extension/ContinuationExt.kt new file mode 100644 index 000000000..8de4ec05b --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/ContinuationExt.kt @@ -0,0 +1,12 @@ +package org.openedx.core.extension + +import kotlinx.coroutines.CancellableContinuation +import kotlin.coroutines.resume + +inline fun CancellableContinuation.safeResume(value: T, onExceptionCalled: () -> Unit) { + if (isActive) { + resume(value) + } else { + onExceptionCalled() + } +} diff --git a/core/src/main/java/org/openedx/core/extension/IntExt.kt b/core/src/main/java/org/openedx/core/extension/IntExt.kt new file mode 100644 index 000000000..5739007f5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/IntExt.kt @@ -0,0 +1,5 @@ +package org.openedx.core.extension + +fun Int.nonZero(): Int? { + return if (this != 0) this else null +} diff --git a/core/src/main/java/org/openedx/core/extension/ListExt.kt b/core/src/main/java/org/openedx/core/extension/ListExt.kt index 2831f917a..1c2a242f7 100644 --- a/core/src/main/java/org/openedx/core/extension/ListExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ListExt.kt @@ -1,5 +1,8 @@ package org.openedx.core.extension +import org.openedx.core.BlockType +import org.openedx.core.domain.model.Block + inline fun List.indexOfFirstFromIndex(startIndex: Int, predicate: (T) -> Boolean): Int { var index = 0 for ((i, item) in this.withIndex()) { @@ -23,3 +26,17 @@ fun MutableList.clearAndAddAll(collection: Collection): MutableList this.addAll(collection) return this } + +fun List.getVerticalBlocks(): List { + return this.filter { it.type == BlockType.VERTICAL } +} + +fun List.getSequentialBlocks(): List { + return this.filter { it.type == BlockType.SEQUENTIAL } +} + +fun List?.isNotEmptyThenLet(block: (List) -> Unit) { + if (!isNullOrEmpty()) { + block(this) + } +} diff --git a/core/src/main/java/org/openedx/core/extension/TextConverter.kt b/core/src/main/java/org/openedx/core/extension/TextConverter.kt index 3c7f04676..6899c1bad 100644 --- a/core/src/main/java/org/openedx/core/extension/TextConverter.kt +++ b/core/src/main/java/org/openedx/core/extension/TextConverter.kt @@ -2,13 +2,17 @@ package org.openedx.core.extension import android.os.Parcelable import android.util.Patterns -import org.openedx.core.BuildConfig import kotlinx.parcelize.Parcelize import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.select.Elements +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.core.config.Config -object TextConverter { +object TextConverter : KoinComponent { + + private val config by inject() fun htmlTextToLinkedText(html: String): LinkedText { val doc: Document = @@ -22,8 +26,8 @@ object TextConverter { } else { link.attr("href") } - if (resultLink.isNotEmpty() && isLinkValid(org.openedx.core.BuildConfig.BASE_URL + resultLink)) { - linksMap[link.text()] = org.openedx.core.BuildConfig.BASE_URL + resultLink + if (resultLink.isNotEmpty() && isLinkValid(config.getApiHostURL() + resultLink)) { + linksMap[link.text()] = config.getApiHostURL() + resultLink } } return LinkedText(text, linksMap.toMap()) @@ -47,8 +51,8 @@ object TextConverter { } else { link.attr("href") } - if (resultLink.isNotEmpty() && isLinkValid(org.openedx.core.BuildConfig.BASE_URL + resultLink)) { - linksMap[link.text()] = org.openedx.core.BuildConfig.BASE_URL + resultLink + if (resultLink.isNotEmpty() && isLinkValid(config.getApiHostURL() + resultLink)) { + linksMap[link.text()] = config.getApiHostURL() + resultLink } } } diff --git a/core/src/main/java/org/openedx/core/extension/UriExt.kt b/core/src/main/java/org/openedx/core/extension/UriExt.kt new file mode 100644 index 000000000..cfa1b44d5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/UriExt.kt @@ -0,0 +1,15 @@ +package org.openedx.core.extension + +import android.net.Uri + +fun Uri.getQueryParams(): Map { + val paramsMap = mutableMapOf() + + queryParameterNames.forEach { name -> + getQueryParameter(name)?.let { value -> + paramsMap[name] = value + } + } + + return paramsMap +} diff --git a/core/src/main/java/org/openedx/core/extension/ViewExt.kt b/core/src/main/java/org/openedx/core/extension/ViewExt.kt index 4fb9bad85..ff2e95d47 100644 --- a/core/src/main/java/org/openedx/core/extension/ViewExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ViewExt.kt @@ -1,8 +1,13 @@ package org.openedx.core.extension import android.content.Context +import android.content.res.Resources +import android.graphics.Rect import android.util.DisplayMetrics import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.DialogFragment fun Context.dpToPixel(dp: Int): Float { return dp * (resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT) @@ -28,5 +33,16 @@ fun View.requestApplyInsetsWhenAttached() { override fun onViewDetachedFromWindow(v: View) = Unit }) } +} + +fun DialogFragment.setWidthPercent(percentage: Int) { + val percent = percentage.toFloat() / 100 + val dm = Resources.getSystem().displayMetrics + val rect = dm.run { Rect(0, 0, widthPixels, heightPixels) } + val percentWidth = rect.width() * percent + dialog?.window?.setLayout(percentWidth.toInt(), ViewGroup.LayoutParams.WRAP_CONTENT) +} -} \ No newline at end of file +fun Context.toastMessage(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() +} diff --git a/core/src/main/java/org/openedx/core/interfaces/EnrollInCourseInteractor.kt b/core/src/main/java/org/openedx/core/interfaces/EnrollInCourseInteractor.kt new file mode 100644 index 000000000..5c82de1f5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/interfaces/EnrollInCourseInteractor.kt @@ -0,0 +1,5 @@ +package org.openedx.core.interfaces + +interface EnrollInCourseInteractor { + suspend fun enrollInACourse(id: String) +} diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt index e7a63db38..551d5b823 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -3,12 +3,15 @@ package org.openedx.core.module import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import android.content.pm.ServiceInfo import android.os.Build import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters +import kotlinx.coroutines.flow.first +import org.koin.java.KoinJavaComponent.inject import org.openedx.core.R import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel @@ -16,9 +19,6 @@ import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.download.CurrentProgress import org.openedx.core.module.download.FileDownloader -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.first -import org.koin.java.KoinJavaComponent.inject import java.io.File class DownloadWorker( @@ -60,15 +60,20 @@ class DownloadWorker( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { createChannel() } + val serviceType = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 + return ForegroundInfo( NOTIFICATION_ID, notificationBuilder - .setSmallIcon(R.drawable.core_ic_check) + .setSmallIcon(R.drawable.core_ic_check_in_box) .setProgress(100, 0, false) .setPriority(NotificationManager.IMPORTANCE_LOW) .setContentText(context.getString(R.string.core_downloading_in_progress)) .setContentTitle("") - .build() + .build(), + serviceType ) } @@ -80,7 +85,7 @@ class DownloadWorker( notificationManager.notify( NOTIFICATION_ID, notificationBuilder - .setSmallIcon(R.drawable.core_ic_check) + .setSmallIcon(R.drawable.core_ic_check_in_box) .setProgress(100, value.toInt(), false) .setPriority(NotificationManager.IMPORTANCE_LOW) .setContentText(context.getString(R.string.core_downloading_in_progress)) diff --git a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt index 08f6b2704..40144325e 100644 --- a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt +++ b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt @@ -3,18 +3,23 @@ package org.openedx.core.module.download import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.openedx.core.config.Config import retrofit2.Retrofit import java.io.File import java.io.FileOutputStream import java.io.InputStream -abstract class AbstractDownloader { +abstract class AbstractDownloader : KoinComponent { + + private val config by inject() protected abstract val client: OkHttpClient private val downloadApi: DownloadApi by lazy { Retrofit.Builder() - .baseUrl(org.openedx.core.BuildConfig.BASE_URL) + .baseUrl(config.getApiHostURL()) .client(client) .build() .create(DownloadApi::class.java) diff --git a/core/src/main/java/org/openedx/core/presentation/catalog/CatalogWebView.kt b/core/src/main/java/org/openedx/core/presentation/catalog/CatalogWebView.kt new file mode 100644 index 000000000..021185ca3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/catalog/CatalogWebView.kt @@ -0,0 +1,99 @@ +package org.openedx.core.presentation.catalog + +import android.annotation.SuppressLint +import android.webkit.WebResourceRequest +import android.webkit.WebView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import org.openedx.core.system.DefaultWebViewClient +import org.openedx.core.presentation.catalog.WebViewLink.Authority as linkAuthority + +@SuppressLint("SetJavaScriptEnabled", "ComposableNaming") +@Composable +fun CatalogWebViewScreen( + url: String, + uriScheme: String, + isAllLinksExternal: Boolean = false, + onWebPageLoaded: () -> Unit, + refreshSessionCookie: () -> Unit = {}, + onWebPageUpdated: (String) -> Unit = {}, + onUriClick: (String, linkAuthority) -> Unit, +): WebView { + val context = LocalContext.current + + return remember { + WebView(context).apply { + webViewClient = object : DefaultWebViewClient( + context = context, + webView = this@apply, + isAllLinksExternal = isAllLinksExternal, + onUriClick = onUriClick, + refreshSessionCookie = refreshSessionCookie, + ) { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + url?.let { onWebPageUpdated(it) } + } + + override fun onPageCommitVisible(view: WebView?, url: String?) { + super.onPageCommitVisible(view, url) + onWebPageLoaded() + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + val clickUrl = request?.url?.toString() ?: "" + if (handleRecognizedLink(clickUrl)) { + return true + } + + return super.shouldOverrideUrlLoading(view, request) + } + + private fun handleRecognizedLink(clickUrl: String): Boolean { + val link = WebViewLink.parse(clickUrl, uriScheme) ?: return false + + return when (link.authority) { + linkAuthority.COURSE_INFO, + linkAuthority.PROGRAM_INFO, + linkAuthority.ENROLLED_PROGRAM_INFO -> { + val pathId = link.params[WebViewLink.Param.PATH_ID] ?: "" + onUriClick(pathId, link.authority) + true + } + + linkAuthority.ENROLL, + linkAuthority.ENROLLED_COURSE_INFO -> { + val courseId = link.params[WebViewLink.Param.COURSE_ID] ?: "" + onUriClick(courseId, link.authority) + true + } + + linkAuthority.COURSE -> { + onUriClick("", link.authority) + true + } + + else -> false + } + } + } + + with(settings) { + javaScriptEnabled = true + loadWithOverviewMode = true + builtInZoomControls = false + setSupportZoom(true) + loadsImagesAutomatically = true + domStorageEnabled = true + } + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + + loadUrl(url) + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/catalog/WebViewLink.kt b/core/src/main/java/org/openedx/core/presentation/catalog/WebViewLink.kt new file mode 100644 index 000000000..f066a3ae8 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/catalog/WebViewLink.kt @@ -0,0 +1,52 @@ +package org.openedx.core.presentation.catalog + +import android.net.Uri +import org.openedx.core.extension.getQueryParams + +/** + * To parse and store links that we need within a WebView. + */ +class WebViewLink( + var authority: Authority, + var params: Map +) { + enum class Authority(val key: String) { + COURSE_INFO("course_info"), + PROGRAM_INFO("program_info"), + ENROLL("enroll"), + ENROLLED_PROGRAM_INFO("enrolled_program_info"), + ENROLLED_COURSE_INFO("enrolled_course_info"), + COURSE("course"), + EXTERNAL("external"), + } + + object Param { + const val PATH_ID = "path_id" + const val COURSE_ID = "course_id" + const val EMAIL_OPT = "email_opt_in" + const val PROGRAMS = "programs" + } + + companion object { + fun parse(uriStr: String?, uriScheme: String): WebViewLink? { + if (uriStr.isNullOrEmpty()) { + return null + } + val sanitizedUriStr = uriStr.replace("+", "%2B") + val uri = Uri.parse(sanitizedUriStr) + + // Validate the URI scheme + if (uriScheme != uri.scheme) { + return null + } + + // Validate the Uri authority + val uriAuthority = Authority.values().find { it.key == uri.authority } ?: return null + + // Parse the Uri params + val params = uri.getQueryParams() + + return WebViewLink(uriAuthority, params) + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt new file mode 100644 index 000000000..e502a136c --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt @@ -0,0 +1,155 @@ +package org.openedx.core.presentation.dialog.alert + +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.koin.android.ext.android.inject +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.presentation.global.app_upgrade.DefaultTextButton +import org.openedx.core.presentation.global.app_upgrade.TransparentTextButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.UrlUtils + +class ActionDialogFragment : DialogFragment() { + + private val config by inject() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + ActionDialog( + title = requireArguments().getString(ARG_TITLE, ""), + message = requireArguments().getString(ARG_MESSAGE, ""), + onPositiveClick = { + dismiss() + }, + onNegativeClick = { + UrlUtils.openInBrowser( + activity = context, + apiHostUrl = config.getApiHostURL(), + url = requireArguments().getString(ARG_URL, ""), + ) + dismiss() + } + ) + } + } + } + + companion object { + private const val ARG_TITLE = "title" + private const val ARG_MESSAGE = "message" + private const val ARG_URL = "url" + + fun newInstance( + title: String, + message: String, + url: String, + ): ActionDialogFragment { + val fragment = ActionDialogFragment() + fragment.arguments = bundleOf( + ARG_TITLE to title, + ARG_MESSAGE to message, + ARG_URL to url, + ) + return fragment + } + } +} + +@Composable +private fun ActionDialog( + title: String, + message: String, + onPositiveClick: () -> Unit, + onNegativeClick: () -> Unit, +) { + Box( + modifier = Modifier + .widthIn(max = 640.dp) + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text( + text = title, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Text( + text = message, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.bodyMedium + ) + Row { + TransparentTextButton( + text = stringResource(R.string.core_cancel), + onClick = onPositiveClick + ) + DefaultTextButton( + text = stringResource(R.string.core_continue), + onClick = onNegativeClick + ) + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ActionDialogPreview() { + ActionDialog( + title = "Leaving the app", + message = "You are now leaving the app and opening a browser.", + onPositiveClick = {}, + onNegativeClick = {}, + ) +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt new file mode 100644 index 000000000..bc41d936d --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt @@ -0,0 +1,127 @@ +package org.openedx.core.presentation.dialog.alert + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.presentation.global.app_upgrade.DefaultTextButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography + +class InfoDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + InfoDialog( + title = requireArguments().getString(ARG_TITLE, ""), + message = requireArguments().getString(ARG_MESSAGE, ""), + onClick = { + dismiss() + }, + ) + } + } + } + + companion object { + private const val ARG_TITLE = "title" + private const val ARG_MESSAGE = "message" + + fun newInstance( + title: String, + message: String, + ): InfoDialogFragment { + val fragment = InfoDialogFragment() + fragment.arguments = bundleOf( + ARG_TITLE to title, + ARG_MESSAGE to message, + ) + return fragment + } + } +} + +@Composable +private fun InfoDialog( + title: String, + message: String, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .widthIn(max = 640.dp) + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text( + text = title, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Text( + text = message, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.bodyMedium + ) + DefaultTextButton( + text = stringResource(R.string.core_ok), + onClick = onClick + ) + } + } +} + +@Preview +@Composable +private fun SimpleDialogPreview() { + InfoDialog( + title = "Important Notice", + message = "This is an important announcement.", + onClick = {} + ) +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewManager.kt new file mode 100644 index 000000000..c825a8e9b --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewManager.kt @@ -0,0 +1,33 @@ +package org.openedx.core.presentation.dialog.appreview + +import androidx.appcompat.app.AppCompatActivity +import org.openedx.core.data.storage.InAppReviewPreferences +import org.openedx.core.presentation.global.AppData + +class AppReviewManager( + private val activity: AppCompatActivity, + private val reviewPreferences: InAppReviewPreferences, + private val appData: AppData +) { + var isDialogShowed = false + + fun tryToOpenRateDialog() { + val supportFragmentManager = activity.supportFragmentManager + if (!supportFragmentManager.isDestroyed) { + isDialogShowed = true + val currentVersionName = reviewPreferences.formatVersionName(appData.versionName) + // Check is app wasn't positive rated AND 2 minor OR 1 major app versions passed since the last review + if ( + !reviewPreferences.wasPositiveRated + && (currentVersionName.minorVersion - 2 >= reviewPreferences.lastReviewVersion.minorVersion + || currentVersionName.majorVersion - 1 >= reviewPreferences.lastReviewVersion.majorVersion) + ) { + val dialog = RateDialogFragment.newInstance() + dialog.show( + supportFragmentManager, + RateDialogFragment::class.simpleName + ) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt new file mode 100644 index 000000000..e2d6a471f --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt @@ -0,0 +1,464 @@ +package org.openedx.core.presentation.dialog.appreview + +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material.icons.outlined.StarOutline +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.R +import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import kotlin.math.round + +@Composable +fun ThankYouDialog( + modifier: Modifier = Modifier, + description: String, + showButtons: Boolean, + onNotNowClick: () -> Unit, + onRateUsClick: () -> Unit +) { + val orientation = LocalConfiguration.current.orientation + val imageModifier = if (orientation == ORIENTATION_LANDSCAPE) { + Modifier.size(40.dp) + } else { + Modifier + } + + DefaultDialogBox( + modifier = modifier, + onDismissClock = onNotNowClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 46.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(28.dp) + ) { + Image( + modifier = imageModifier, + painter = painterResource(id = R.drawable.core_ic_heart), + contentScale = ContentScale.FillBounds, + contentDescription = null + ) + Text( + text = stringResource(R.string.core_thank_you), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Text( + text = description, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.bodyMedium + ) + + if (showButtons) { + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + TransparentTextButton( + text = stringResource(id = R.string.core_not_now), + onClick = onNotNowClick + ) + DefaultTextButton( + text = stringResource(id = R.string.core_rate_us), + onClick = onRateUsClick + ) + } + } + } + } +} + +@Composable +fun FeedbackDialog( + modifier: Modifier = Modifier, + feedback: MutableState, + onNotNowClick: () -> Unit, + onShareClick: () -> Unit +) { + val orientation = LocalConfiguration.current.orientation + val textFieldModifier = if (orientation == ORIENTATION_LANDSCAPE) { + Modifier.height(80.dp) + } else { + Modifier.height(162.dp) + } + + DefaultDialogBox( + modifier = modifier, + onDismissClock = onNotNowClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text( + text = stringResource(R.string.core_feedback_dialog_title), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Text( + text = stringResource(id = R.string.core_feedback_dialog_description), + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.bodyMedium + ) + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .then(textFieldModifier), + value = feedback.value, + onValueChange = { str -> + feedback.value = str + }, + textStyle = MaterialTheme.appTypography.labelLarge, + shape = MaterialTheme.appShapes.buttonShape, + placeholder = { + Text( + text = stringResource(id = R.string.core_feedback_dialog_textfield_hint), + color = MaterialTheme.appColors.textFieldHint, + style = MaterialTheme.appTypography.labelLarge, + ) + }, + colors = TextFieldDefaults.outlinedTextFieldColors( + backgroundColor = MaterialTheme.appColors.cardViewBackground, + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + textColor = MaterialTheme.appColors.textFieldText + ), + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + TransparentTextButton( + text = stringResource(id = R.string.core_not_now), + onClick = onNotNowClick + ) + DefaultTextButton( + isEnabled = feedback.value.isNotEmpty(), + text = stringResource(id = R.string.core_share_feedback), + onClick = onShareClick + ) + } + } + } +} + +@Composable +fun RateDialog( + modifier: Modifier = Modifier, + rating: MutableIntState, + onNotNowClick: () -> Unit, + onSubmitClick: () -> Unit +) { + DefaultDialogBox( + modifier = modifier, + onDismissClock = onNotNowClick + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text( + text = stringResource(R.string.core_rate_dialog_title, stringResource(R.string.app_name)), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Text( + text = stringResource(id = R.string.core_rate_dialog_description), + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.bodyMedium + ) + RatingBar( + modifier = Modifier + .padding(vertical = 12.dp), + rating = rating + ) + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + TransparentTextButton( + text = stringResource(id = R.string.core_not_now), + onClick = onNotNowClick + ) + DefaultTextButton( + isEnabled = rating.intValue > 0, + text = stringResource(id = R.string.core_submit), + onClick = onSubmitClick + ) + } + } + } +} + +@Composable +fun DefaultDialogBox( + modifier: Modifier = Modifier, + onDismissClock: () -> Unit, + content: @Composable (BoxScope.() -> Unit) +) { + Surface( + modifier = modifier, + color = Color.Transparent + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 4.dp) + .noRippleClickable { + onDismissClock() + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .widthIn(max = 640.dp) + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .noRippleClickable {} + .background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ) + ) { + content.invoke(this) + } + } + } +} + +@Composable +fun TransparentTextButton( + text: String, + onClick: () -> Unit +) { + Button( + modifier = Modifier + .height(42.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent + ), + elevation = null, + shape = MaterialTheme.appShapes.navigationButtonShape, + onClick = onClick + ) { + Text( + color = MaterialTheme.appColors.textAccent, + style = MaterialTheme.appTypography.labelLarge, + text = text + ) + } +} + +@Composable +fun DefaultTextButton( + isEnabled: Boolean = true, + text: String, + onClick: () -> Unit +) { + val textColor: Color + val backgroundColor: Color + if (isEnabled) { + textColor = MaterialTheme.appColors.buttonText + backgroundColor = MaterialTheme.appColors.buttonBackground + } else { + textColor = MaterialTheme.appColors.inactiveButtonText + backgroundColor = MaterialTheme.appColors.inactiveButtonBackground + } + + Button( + modifier = Modifier + .height(42.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = backgroundColor, + contentColor = textColor + ), + elevation = null, + shape = MaterialTheme.appShapes.navigationButtonShape, + enabled = isEnabled, + onClick = onClick + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = text, + style = MaterialTheme.appTypography.labelLarge + ) + } + } +} + +@Composable +fun RatingBar( + modifier: Modifier = Modifier, + rating: MutableIntState, + stars: Int = 5, + starsColor: Color = MaterialTheme.appColors.rateStars, +) { + val density = LocalDensity.current + var componentWight by remember { mutableStateOf(0.dp) } + var maxXValue by remember { mutableFloatStateOf(0f) } + val startSize = componentWight / stars + val filledStars = rating.intValue + val unfilledStars = stars - rating.intValue + + Row( + modifier = modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + maxXValue = coordinates.size.width + coordinates.positionInRoot().x + componentWight = with(density) { + coordinates.size.width.toDp() + } + } + .pointerInput(Unit) { + detectTapGestures { offset -> + rating.intValue = round(offset.x / maxXValue * stars + 0.8f).toInt() + } + }, + horizontalArrangement = Arrangement.Center + ) { + repeat(filledStars) { + Icon( + modifier = Modifier.size(startSize), + imageVector = Icons.Outlined.Star, + contentDescription = null, + tint = starsColor + ) + } + repeat(unfilledStars) { + Icon( + modifier = Modifier.size(startSize), + imageVector = Icons.Outlined.StarOutline, + contentDescription = null, + tint = if (isSystemInDarkTheme()) Color.White else Color.Black + ) + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +fun RatingBarPreview() { + OpenEdXTheme { + RatingBar( + rating = remember { mutableIntStateOf(2) } + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun RateDialogPreview() { + OpenEdXTheme { + RateDialog( + rating = remember { mutableIntStateOf(2) }, + onNotNowClick = {}, + onSubmitClick = {} + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun FeedbackDialogPreview() { + OpenEdXTheme { + FeedbackDialog( + feedback = remember { mutableStateOf("Feedback") }, + onNotNowClick = {}, + onShareClick = {} + ) + } +} + +@Preview +@Composable +private fun ThankYouDialogWithButtonsPreview() { + OpenEdXTheme { + ThankYouDialog( + description = "Description", + showButtons = true, + onNotNowClick = {}, + onRateUsClick = {} + ) + } +} + +@Preview +@Composable +private fun ThankYouDialogWithoutButtonsPreview() { + OpenEdXTheme { + ThankYouDialog( + description = "Description", + showButtons = false, + onNotNowClick = {}, + onRateUsClick = {} + ) + } +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/BaseAppReviewDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/BaseAppReviewDialogFragment.kt new file mode 100644 index 000000000..427c71959 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/BaseAppReviewDialogFragment.kt @@ -0,0 +1,26 @@ +package org.openedx.core.presentation.dialog.appreview + +import androidx.fragment.app.DialogFragment +import org.koin.android.ext.android.inject +import org.openedx.core.data.storage.InAppReviewPreferences +import org.openedx.core.presentation.global.AppData + +open class BaseAppReviewDialogFragment : DialogFragment() { + + private val reviewPreferences: InAppReviewPreferences by inject() + protected val appData: AppData by inject() + + fun saveVersionName() { + val versionName = appData.versionName + reviewPreferences.setVersion(versionName) + } + + fun onPositiveRate() { + reviewPreferences.wasPositiveRated = true + } + + fun notNowClick() { + saveVersionName() + dismiss() + } +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/FeedbackDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/FeedbackDialogFragment.kt new file mode 100644 index 000000000..f882cbe8a --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/FeedbackDialogFragment.kt @@ -0,0 +1,83 @@ +package org.openedx.core.presentation.dialog.appreview + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import org.koin.android.ext.android.inject +import org.openedx.core.config.Config +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.utils.EmailUtil + +class FeedbackDialogFragment : BaseAppReviewDialogFragment() { + + private val config by inject() + private var wasShareClicked = false + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + if (dialog != null && dialog!!.window != null) { + dialog!!.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val feedback = rememberSaveable { mutableStateOf("") } + FeedbackDialog( + feedback = feedback, + onNotNowClick = this@FeedbackDialogFragment::notNowClick, + onShareClick = { + onShareClick(feedback.value) + } + ) + } + } + } + + override fun onResume() { + super.onResume() + if (wasShareClicked) { + openThankYouDialog() + dismiss() + } + } + + private fun onShareClick(feedback: String) { + saveVersionName() + wasShareClicked = true + sendEmail(feedback) + } + + private fun sendEmail(feedback: String) { + EmailUtil.showFeedbackScreen( + context = requireContext(), + feedbackEmailAddress = config.getFeedbackEmailAddress(), + feedback = feedback, + appVersion = appData.versionName + ) + } + + private fun openThankYouDialog() { + val dialog = ThankYouDialogFragment.newInstance( + isFeedbackPositive = false + ) + dialog.show( + requireActivity().supportFragmentManager, + ThankYouDialogFragment::class.simpleName + ) + } + + companion object { + fun newInstance(): FeedbackDialogFragment { + return FeedbackDialogFragment() + } + } +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/RateDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/RateDialogFragment.kt new file mode 100644 index 000000000..1cfa034b9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/RateDialogFragment.kt @@ -0,0 +1,70 @@ +package org.openedx.core.presentation.dialog.appreview + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import org.openedx.core.ui.theme.OpenEdXTheme + +class RateDialogFragment: BaseAppReviewDialogFragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + if (dialog != null && dialog!!.window != null) { + dialog!!.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val rating = rememberSaveable { mutableIntStateOf(0) } + RateDialog( + rating = rating, + onNotNowClick = this@RateDialogFragment::notNowClick, + onSubmitClick = { + onSubmitClick(rating.intValue) + } + ) + } + } + } + + private fun onSubmitClick(rating: Int) { + dismiss() + if (rating > 3) { + openThankYouDialog() + } else { + openFeedbackDialog() + } + } + + private fun openFeedbackDialog() { + val dialog = FeedbackDialogFragment.newInstance() + dialog.show( + requireActivity().supportFragmentManager, + FeedbackDialogFragment::class.simpleName + ) + } + + private fun openThankYouDialog() { + val dialog = ThankYouDialogFragment.newInstance( + isFeedbackPositive = true + ) + dialog.show( + requireActivity().supportFragmentManager, + ThankYouDialogFragment::class.simpleName + ) + } + + companion object { + fun newInstance(): RateDialogFragment { + return RateDialogFragment() + } + } +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/ThankYouDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/ThankYouDialogFragment.kt new file mode 100644 index 000000000..89fe98c1c --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/ThankYouDialogFragment.kt @@ -0,0 +1,100 @@ +package org.openedx.core.presentation.dialog.appreview + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.core.os.bundleOf +import androidx.lifecycle.lifecycleScope +import com.google.android.play.core.review.ReviewException +import com.google.android.play.core.review.ReviewManager +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import org.openedx.core.R +import org.openedx.core.ui.theme.OpenEdXTheme + +class ThankYouDialogFragment : BaseAppReviewDialogFragment() { + + private val reviewManager: ReviewManager by inject() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + if (dialog != null && dialog!!.window != null) { + dialog!!.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val isFeedbackPositive = rememberSaveable { + mutableStateOf(requireArguments().getBoolean(ARG_IS_FEEDBACK_POSITIVE)) + } + val description = if (isFeedbackPositive.value) { + stringResource(id = R.string.core_thank_you_dialog_positive_description) + } else { + stringResource(id = R.string.core_thank_you_dialog_negative_description) + } + + ThankYouDialog( + description = description, + showButtons = isFeedbackPositive.value, + onNotNowClick = this@ThankYouDialogFragment::notNowClick, + onRateUsClick = this@ThankYouDialogFragment::openInAppReview + ) + + closeDialogDelay(isFeedbackPositive.value) + } + } + } + + private fun closeDialogDelay(isFeedbackPositive: Boolean) { + if (!isFeedbackPositive) { + lifecycleScope.launch { + delay(3000) + dismiss() + } + } + } + + private fun openInAppReview() { + val request = reviewManager.requestReviewFlow() + request.addOnCompleteListener { task -> + try { + if (request.isSuccessful) { + val reviewInfo = task.result + val flow = reviewManager.launchReviewFlow(requireActivity(), reviewInfo) + flow.addOnCompleteListener { _ -> + onPositiveRate() + } + dismiss() + } + } catch (e: ReviewException) { + e.printStackTrace() + } + } + } + + companion object { + + private const val ARG_IS_FEEDBACK_POSITIVE = "is_feedback_positive" + + fun newInstance( + isFeedbackPositive: Boolean + ): ThankYouDialogFragment { + val fragment = ThankYouDialogFragment() + fragment.arguments = bundleOf( + ARG_IS_FEEDBACK_POSITIVE to isFeedbackPositive + ) + return fragment + } + } +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt new file mode 100644 index 000000000..4c5c4ce56 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt @@ -0,0 +1,52 @@ +package org.openedx.core.presentation.dialog.appupgrade + +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.DialogFragment +import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendDialog +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.AppUpdateState + +class AppUpgradeDialogFragment : DialogFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + if (dialog != null && dialog!!.window != null) { + dialog!!.window?.setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) + } + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + AppUpgradeRecommendDialog( + onNotNowClick = this@AppUpgradeDialogFragment::onNotNowClick, + onUpdateClick = this@AppUpgradeDialogFragment::onUpdateClick + ) + } + } + } + + private fun onNotNowClick() { + AppUpdateState.wasUpdateDialogClosed.value = true + dismiss() + } + + private fun onUpdateClick() { + AppUpdateState.wasUpdateDialogClosed.value = true + dismiss() + AppUpdateState.openPlayMarket(requireContext()) + } + + companion object { + fun newInstance(): AppUpgradeDialogFragment { + return AppUpgradeDialogFragment() + } + } + +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/SelectBottomDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt similarity index 98% rename from core/src/main/java/org/openedx/core/presentation/dialog/SelectBottomDialogFragment.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt index 8d15d6973..e2b6bdd58 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/SelectBottomDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.dialog +package org.openedx.core.presentation.dialog.selectorbottomsheet import android.graphics.Color import android.graphics.drawable.ColorDrawable @@ -29,16 +29,15 @@ import androidx.fragment.app.DialogFragment import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.R import org.openedx.core.domain.model.RegistrationField import org.openedx.core.extension.parcelableArrayList import org.openedx.core.ui.SheetContent -import org.openedx.core.ui.rememberWindowSize -import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.core.ui.theme.appColors -import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.ui.isImeVisibleState import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes class SelectBottomDialogFragment : BottomSheetDialogFragment() { diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/SelectDialogViewModel.kt b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt similarity index 90% rename from core/src/main/java/org/openedx/core/presentation/dialog/SelectDialogViewModel.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt index 07a2f1b74..6a09f5724 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/SelectDialogViewModel.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.dialog +package org.openedx.core.presentation.dialog.selectorbottomsheet import androidx.lifecycle.viewModelScope import org.openedx.core.BaseViewModel diff --git a/core/src/main/java/org/openedx/core/presentation/global/AppData.kt b/core/src/main/java/org/openedx/core/presentation/global/AppData.kt new file mode 100644 index 000000000..324d3325a --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/global/AppData.kt @@ -0,0 +1,5 @@ +package org.openedx.core.presentation.global + +data class AppData( + val versionName: String, +) diff --git a/core/src/main/java/org/openedx/core/presentation/global/AppDataHolder.kt b/core/src/main/java/org/openedx/core/presentation/global/AppDataHolder.kt deleted file mode 100644 index d10024135..000000000 --- a/core/src/main/java/org/openedx/core/presentation/global/AppDataHolder.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.openedx.core.presentation.global - -interface AppDataHolder { - val appData: AppData -} - -data class AppData( - val versionName: String -) \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/presentation/global/InsetHolder.kt b/core/src/main/java/org/openedx/core/presentation/global/InsetHolder.kt index 8dd5551de..26996f162 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/InsetHolder.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/InsetHolder.kt @@ -4,4 +4,5 @@ package org.openedx.core.presentation.global interface InsetHolder { val topInset: Int val bottomInset: Int + val cutoutInset: Int } \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/presentation/global/WhatsNewGlobalManager.kt b/core/src/main/java/org/openedx/core/presentation/global/WhatsNewGlobalManager.kt new file mode 100644 index 000000000..e2cf46a4e --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/global/WhatsNewGlobalManager.kt @@ -0,0 +1,5 @@ +package org.openedx.core.presentation.global + +interface WhatsNewGlobalManager { + fun shouldShowWhatsNew(): Boolean +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt b/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt new file mode 100644 index 000000000..cfa53da7c --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpdateUI.kt @@ -0,0 +1,383 @@ +package org.openedx.core.presentation.global.app_upgrade + +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.R +import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography + +@Composable +fun AppUpgradeRequiredScreen( + modifier: Modifier = Modifier, + onUpdateClick: () -> Unit +) { + AppUpgradeRequiredScreen( + modifier = modifier, + showAccountSettingsButton = false, + onAccountSettingsClick = {}, + onUpdateClick = onUpdateClick + ) +} + +@Composable +fun AppUpgradeRequiredScreen( + modifier: Modifier = Modifier, + showAccountSettingsButton: Boolean, + onAccountSettingsClick: () -> Unit, + onUpdateClick: () -> Unit +) { + Box( + modifier = modifier + .fillMaxSize() + .background(color = MaterialTheme.appColors.background) + .statusBarsInset(), + contentAlignment = Alignment.TopCenter + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp, bottom = 12.dp), + text = stringResource(id = R.string.core_deprecated_app_version), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + AppUpgradeRequiredContent( + modifier = Modifier.padding(horizontal = 32.dp), + showAccountSettingsButton = showAccountSettingsButton, + onAccountSettingsClick = onAccountSettingsClick, + onUpdateClick = onUpdateClick + ) + } + } +} + +@Composable +fun AppUpgradeRecommendDialog( + modifier: Modifier = Modifier, + onNotNowClick: () -> Unit, + onUpdateClick: () -> Unit +) { + val orientation = LocalConfiguration.current.orientation + val imageModifier = if (orientation == ORIENTATION_LANDSCAPE) { + Modifier.size(60.dp) + } else { + Modifier + } + + Surface( + modifier = modifier, + color = Color.Transparent + ) { + Box( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 4.dp) + .noRippleClickable { + onNotNowClick() + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .widthIn(max = 640.dp) + .fillMaxWidth() + .clip(MaterialTheme.appShapes.cardShape) + .noRippleClickable {} + .background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Image( + modifier = imageModifier, + painter = painterResource(id = R.drawable.core_ic_icon_upgrade), + contentDescription = null + ) + Text( + text = stringResource(id = R.string.core_app_upgrade_title), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Text( + text = stringResource(id = R.string.core_app_upgrade_dialog_description), + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.bodyMedium + ) + AppUpgradeDialogButtons( + onNotNowClick = onNotNowClick, + onUpdateClick = onUpdateClick + ) + } + } + } + } +} + +@Composable +fun AppUpgradeRequiredContent( + modifier: Modifier = Modifier, + showAccountSettingsButton: Boolean, + onAccountSettingsClick: () -> Unit, + onUpdateClick: () -> Unit +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp) + ) { + Image( + painter = painterResource(id = R.drawable.core_ic_warning), + contentDescription = null + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(id = R.string.core_app_update_required_title), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Text( + text = stringResource(id = R.string.core_app_update_required_description), + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.bodyMedium + ) + } + AppUpgradeRequiredButtons( + showAccountSettingsButton = showAccountSettingsButton, + onAccountSettingsClick = onAccountSettingsClick, + onUpdateClick = onUpdateClick + ) + } +} + +@Composable +fun AppUpgradeRequiredButtons( + showAccountSettingsButton: Boolean, + onAccountSettingsClick: () -> Unit, + onUpdateClick: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + if (showAccountSettingsButton) { + TransparentTextButton( + text = stringResource(id = R.string.core_account_settings), + onClick = onAccountSettingsClick + ) + } + DefaultTextButton( + text = stringResource(id = R.string.core_update), + onClick = onUpdateClick + ) + } +} + +@Composable +fun AppUpgradeDialogButtons( + onNotNowClick: () -> Unit, + onUpdateClick: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + TransparentTextButton( + text = stringResource(id = R.string.core_not_now), + onClick = onNotNowClick + ) + DefaultTextButton( + text = stringResource(id = R.string.core_update), + onClick = onUpdateClick + ) + } +} + +@Composable +fun TransparentTextButton( + text: String, + onClick: () -> Unit +) { + Button( + modifier = Modifier + .height(42.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color.Transparent + ), + elevation = null, + shape = MaterialTheme.appShapes.navigationButtonShape, + onClick = onClick + ) { + Text( + color = MaterialTheme.appColors.textAccent, + style = MaterialTheme.appTypography.labelLarge, + text = text + ) + } +} + +@Composable +fun DefaultTextButton( + text: String, + onClick: () -> Unit +) { + Button( + modifier = Modifier + .height(42.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.appColors.buttonBackground + ), + elevation = null, + shape = MaterialTheme.appShapes.navigationButtonShape, + onClick = onClick + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = text, + color = MaterialTheme.appColors.buttonText, + style = MaterialTheme.appTypography.labelLarge + ) + } + } +} + +@Composable +fun AppUpgradeRecommendedBox( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(20.dp) + .clickable { + onClick() + }, + shape = MaterialTheme.appShapes.cardShape, + backgroundColor = MaterialTheme.appColors.primary + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + modifier = Modifier.size(40.dp), + painter = painterResource(id = R.drawable.core_ic_icon_upgrade), + contentDescription = null, + tint = Color.White + ) + Column { + Text( + text = stringResource(id = R.string.core_app_upgrade_title), + color = Color.White, + style = MaterialTheme.appTypography.titleMedium + ) + Text( + text = stringResource(id = R.string.core_app_upgrade_box_description), + color = Color.White, + style = MaterialTheme.appTypography.bodyMedium + ) + } + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun AppUpgradeRequiredScreenPreview() { + OpenEdXTheme { + AppUpgradeRequiredScreen( + showAccountSettingsButton = true, + onAccountSettingsClick = {}, + onUpdateClick = {} + ) + } +} + +@Preview +@Composable +private fun AppUpgradeRecommendedBoxPreview() { + OpenEdXTheme { + AppUpgradeRecommendedBox( + onClick = {} + ) + } +} + +@Preview +@Composable +private fun AppUpgradeDialogButtonsPreview() { + OpenEdXTheme { + AppUpgradeDialogButtons( + onNotNowClick = {}, + onUpdateClick = {} + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun AppUpgradeRecommendDialogPreview() { + OpenEdXTheme { + AppUpgradeRecommendDialog( + onNotNowClick = {}, + onUpdateClick = {} + ) + } +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpgradeRouter.kt b/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpgradeRouter.kt new file mode 100644 index 000000000..482c91093 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/AppUpgradeRouter.kt @@ -0,0 +1,7 @@ +package org.openedx.core.presentation.global.app_upgrade + +import androidx.fragment.app.FragmentManager + +interface AppUpgradeRouter { + fun navigateToUserProfile(fm: FragmentManager) +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/UpgradeRequiredFragment.kt b/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/UpgradeRequiredFragment.kt new file mode 100644 index 000000000..da8685435 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/global/app_upgrade/UpgradeRequiredFragment.kt @@ -0,0 +1,42 @@ +package org.openedx.core.presentation.global.app_upgrade + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.AppUpdateState + +class UpgradeRequiredFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + AppUpgradeRequiredScreen( + showAccountSettingsButton = true, + onAccountSettingsClick = { + setFragmentResult(REQUEST_KEY, bundleOf(OPEN_ACCOUNT_SETTINGS_KEY to "")) + parentFragmentManager.popBackStack() + }, + onUpdateClick = { + AppUpdateState.openPlayMarket(requireContext()) + } + ) + } + } + } + + companion object { + const val REQUEST_KEY = "UpgradeRequiredFragmentRequestKey" + const val OPEN_ACCOUNT_SETTINGS_KEY = "openAccountSettings" + } +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt b/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt new file mode 100644 index 000000000..b1a496743 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt @@ -0,0 +1,60 @@ +package org.openedx.core.presentation.global.webview + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import android.webkit.CookieManager +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import org.koin.android.ext.android.inject +import org.openedx.core.config.Config +import org.openedx.core.ui.WebContentScreen +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.theme.OpenEdXTheme + +class WebContentFragment : Fragment() { + + private val config: Config by inject() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + WebContentScreen( + apiHostUrl = config.getApiHostURL(), + windowSize = windowSize, + title = requireArguments().getString(ARG_TITLE, ""), + contentUrl = requireArguments().getString(ARG_URL, ""), + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }) + } + } + } + + override fun onDestroy() { + super.onDestroy() + CookieManager.getInstance().flush() + } + + companion object { + private const val ARG_TITLE = "argTitle" + private const val ARG_URL = "argUrl" + + fun newInstance(title: String, url: String): WebContentFragment { + val fragment = WebContentFragment() + fragment.arguments = bundleOf( + ARG_TITLE to title, + ARG_URL to url, + ) + return fragment + } + } +} diff --git a/core/src/main/java/org/openedx/core/system/AppCookieManager.kt b/core/src/main/java/org/openedx/core/system/AppCookieManager.kt index 40f4ec015..f09e16362 100644 --- a/core/src/main/java/org/openedx/core/system/AppCookieManager.kt +++ b/core/src/main/java/org/openedx/core/system/AppCookieManager.kt @@ -1,16 +1,16 @@ package org.openedx.core.system import android.webkit.CookieManager -import org.openedx.core.BuildConfig -import org.openedx.core.data.api.CookiesApi import okhttp3.Cookie import okhttp3.RequestBody +import org.openedx.core.config.Config +import org.openedx.core.data.api.CookiesApi import retrofit2.Response import java.util.concurrent.TimeUnit -class AppCookieManager(private val api: CookiesApi) { +class AppCookieManager(private val config: Config, private val api: CookiesApi) { - companion object{ + companion object { private const val REV_934_COOKIE = "REV_934=mobile; expires=Tue, 31 Dec 2021 12:00:20 GMT; domain=.edx.org;" private val FRESHNESS_INTERVAL = TimeUnit.HOURS.toMillis(1) @@ -25,7 +25,7 @@ class AppCookieManager(private val api: CookiesApi) { clearWebViewCookie() val cookieManager = CookieManager.getInstance() for (cookie in Cookie.parseAll(response!!.raw().request.url, response!!.headers())) { - cookieManager.setCookie(org.openedx.core.BuildConfig.BASE_URL,cookie.toString()) + cookieManager.setCookie(config.getApiHostURL(), cookie.toString()) } authSessionCookieExpiration = System.currentTimeMillis() + FRESHNESS_INTERVAL } catch (e: Exception) { @@ -46,7 +46,7 @@ class AppCookieManager(private val api: CookiesApi) { } fun setMobileCookie() { - CookieManager.getInstance().setCookie(org.openedx.core.BuildConfig.BASE_URL, REV_934_COOKIE) + CookieManager.getInstance().setCookie(config.getApiHostURL(), REV_934_COOKIE) } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/system/DefaultWebViewClient.kt b/core/src/main/java/org/openedx/core/system/DefaultWebViewClient.kt new file mode 100644 index 000000000..1273685c0 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/DefaultWebViewClient.kt @@ -0,0 +1,85 @@ +package org.openedx.core.system + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import org.openedx.core.extension.isEmailValid +import org.openedx.core.presentation.catalog.WebViewLink +import org.openedx.core.utils.EmailUtil + +open class DefaultWebViewClient( + val context: Context, + val webView: WebView, + val isAllLinksExternal: Boolean, + val refreshSessionCookie: () -> Unit, + val onUriClick: (String, WebViewLink.Authority) -> Unit, +) : WebViewClient() { + + private var hostForThisPage: String? = null + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + + if (hostForThisPage == null && url != null) { + hostForThisPage = Uri.parse(url).host + } + } + + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + val clickUrl = request?.url?.toString() ?: "" + + if (clickUrl.isNotEmpty() && (isAllLinksExternal || isExternalLink(clickUrl))) { + onUriClick(clickUrl, WebViewLink.Authority.EXTERNAL) + return true + } + + return if (clickUrl.startsWith("mailto:")) { + val email = clickUrl.replace("mailto:", "") + if (email.isEmailValid()) { + EmailUtil.sendEmailIntent(context, email, "", "") + true + } else { + false + } + } else { + false + } + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + if (request.url.toString() == view.url) { + when (errorResponse.statusCode) { + 403, 401, 404 -> { + refreshSessionCookie() + webView.loadUrl(request.url.toString()) + } + } + } + super.onReceivedHttpError(view, request, errorResponse) + } + + private fun isExternalLink(strUrl: String?): Boolean { + return strUrl?.let { url -> + val uri = Uri.parse(url) + val externalLinkValue = if (uri.isHierarchical) { + uri.getQueryParameter(QUERY_PARAM_EXTERNAL_LINK) + } else { + null + } + hostForThisPage != null && hostForThisPage != uri.host || + externalLinkValue?.toBoolean() == true + } ?: false + } + + companion object { + const val QUERY_PARAM_EXTERNAL_LINK = "external_link" + } +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeEvent.kt new file mode 100644 index 000000000..f99086a11 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeEvent.kt @@ -0,0 +1,6 @@ +package org.openedx.core.system.notifier + +sealed class AppUpgradeEvent { + object UpgradeRequiredEvent : AppUpgradeEvent() + class UpgradeRecommendedEvent(val newVersionName: String) : AppUpgradeEvent() +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt new file mode 100644 index 000000000..0f5a274d5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/AppUpgradeNotifier.kt @@ -0,0 +1,15 @@ +package org.openedx.core.system.notifier + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class AppUpgradeNotifier { + + private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) + + val notifier: Flow = channel.asSharedFlow() + + suspend fun send(event: AppUpgradeEvent) = channel.emit(event) + +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseCompletionSet.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseCompletionSet.kt new file mode 100644 index 000000000..ae2450a9c --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseCompletionSet.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +class CourseCompletionSet : CourseEvent \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt index 93b00e722..3b5c48099 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt @@ -15,5 +15,6 @@ class CourseNotifier { suspend fun send(event: CourseDashboardUpdate) = channel.emit(event) suspend fun send(event: CourseSubtitleLanguageChanged) = channel.emit(event) suspend fun send(event: CourseSectionChanged) = channel.emit(event) + suspend fun send(event: CourseCompletionSet) = channel.emit(event) } \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt index a06a6b7e2..af7a0583e 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt @@ -2,5 +2,6 @@ package org.openedx.core.system.notifier data class CourseVideoPositionChanged( val videoUrl: String, - val videoTime: Long + val videoTime: Long, + val isPlaying: Boolean ) : CourseEvent \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index af884aef7..80f61d75d 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1,30 +1,66 @@ package org.openedx.core.ui +import android.content.Context import android.os.Build.VERSION.SDK_INT import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.* +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.ScaffoldState +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search -import androidx.compose.runtime.* +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.focus.* +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector @@ -33,15 +69,23 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.* +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.* +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import coil.ImageLoader @@ -54,6 +98,7 @@ import org.openedx.core.UIMessage import org.openedx.core.domain.model.Course import org.openedx.core.domain.model.RegistrationField import org.openedx.core.extension.LinkedImageText +import org.openedx.core.extension.toastMessage import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography @@ -96,6 +141,34 @@ fun StaticSearchBar( } } +@Composable +fun Toolbar( + modifier: Modifier = Modifier, + label: String, + canShowBackBtn: Boolean = false, + onBackClick: () -> Unit = {} +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(48.dp), + ) { + if (canShowBackBtn) { + BackBtn(onBackClick = onBackClick) + } + + Text( + modifier = Modifier + .align(Alignment.Center), + text = label, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } +} @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -150,7 +223,9 @@ fun SearchBar( ), placeholder = { Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .testTag("txt_search_placeholder") + .fillMaxWidth(), text = label, color = MaterialTheme.appColors.textSecondary, style = MaterialTheme.appTypography.bodyMedium @@ -291,8 +366,7 @@ fun HandleUIMessage( } is UIMessage.ToastMessage -> { - val message = uiMessage.message - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + context.toastMessage(uiMessage.message) } else -> {} @@ -510,6 +584,7 @@ fun SheetContent( ) { Text( modifier = Modifier + .testTag("txt_selection_title") .fillMaxWidth() .padding(10.dp), textAlign = TextAlign.Center, @@ -518,6 +593,7 @@ fun SheetContent( ) SearchBarStateless( modifier = Modifier + .testTag("sb_search") .fillMaxWidth() .height(48.dp) .padding(horizontal = 16.dp), @@ -538,6 +614,7 @@ fun SheetContent( }) { item -> Text( modifier = Modifier + .testTag("txt_${item.value}_title") .fillMaxWidth() .padding(horizontal = 16.dp) .clickable { @@ -730,7 +807,13 @@ fun AutoSizeText( } @Composable -fun DiscoveryCourseItem(course: Course, windowSize: WindowSize, onClick: (String) -> Unit) { +fun DiscoveryCourseItem( + apiHostUrl: String, + course: Course, + windowSize: WindowSize, + onClick: (String) -> Unit +) { + val imageWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -740,7 +823,7 @@ fun DiscoveryCourseItem(course: Course, windowSize: WindowSize, onClick: (String ) } - val imageUrl = org.openedx.core.BuildConfig.BASE_URL.dropLast(1) + course.media.courseImage?.uri + val imageUrl = apiHostUrl.dropLast(1) + course.media.courseImage?.uri Surface( modifier = Modifier .fillMaxWidth() @@ -885,6 +968,7 @@ fun TextIcon( painter: Painter, color: Color, textStyle: TextStyle = MaterialTheme.appTypography.bodySmall, + iconModifier: Modifier = Modifier, onClick: (() -> Unit)? = null, ) { val modifier = if (onClick == null) { @@ -899,7 +983,8 @@ fun TextIcon( ) { Text(text = text, color = color, style = textStyle) Icon( - modifier = Modifier.size((textStyle.fontSize.value + 4).dp), + modifier = iconModifier + .size((textStyle.fontSize.value + 4).dp), painter = painter, contentDescription = null, tint = color @@ -951,7 +1036,7 @@ fun OfflineModeDialog( @Composable fun OpenEdXButton( width: Modifier = Modifier.fillMaxWidth(), - text: String, + text: String = "", onClick: () -> Unit, enabled: Boolean = true, backgroundColor: Color = MaterialTheme.appColors.buttonBackground, @@ -986,7 +1071,7 @@ fun OpenEdXOutlinedButton( backgroundColor: Color = Color.Transparent, borderColor: Color, textColor: Color, - text: String, + text: String = "", onClick: () -> Unit, content: (@Composable RowScope.() -> Unit)? = null ) { @@ -1017,7 +1102,7 @@ fun BackBtn( tint: Color = MaterialTheme.appColors.primary, onBackClick: () -> Unit ) { - IconButton(modifier = modifier, + IconButton(modifier = modifier.testTag("ib_back"), onClick = { onBackClick() }) { Icon( painter = painterResource(id = R.drawable.core_ic_back), @@ -1027,6 +1112,68 @@ fun BackBtn( } } +@Composable +fun ConnectionErrorView( + modifier: Modifier, + onReloadClick: () -> Unit +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(100.dp), + imageVector = Icons.Filled.Wifi, + contentDescription = null, + tint = MaterialTheme.appColors.onSurface + ) + Spacer(Modifier.height(16.dp)) + Text( + modifier = Modifier.fillMaxWidth(0.6f), + text = stringResource(id = R.string.core_not_connected_to_internet), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(16.dp)) + OpenEdXButton( + width = Modifier + .widthIn(Dp.Unspecified, 162.dp), + text = stringResource(id = R.string.core_reload), + onClick = onReloadClick + ) + } +} + +@Composable +fun AuthButtonsPanel( + onRegisterClick: () -> Unit, + onSignInClick: () -> Unit +) { + Row { + OpenEdXButton( + width = Modifier + .testTag("btn_register") + .width(0.dp) + .weight(1f), + text = stringResource(id = R.string.core_register), + onClick = { onRegisterClick() } + ) + + OpenEdXOutlinedButton( + modifier = Modifier + .testTag("btn_sign_in") + .width(100.dp) + .padding(start = 16.dp), + text = stringResource(id = R.string.core_sign_in), + onClick = { onSignInClick() }, + borderColor = MaterialTheme.appColors.textFieldBorder, + textColor = MaterialTheme.appColors.primary + ) + } +} + @Preview @Composable private fun StaticSearchBarPreview() { @@ -1049,3 +1196,23 @@ private fun SearchBarPreview() { onClearValue = {} ) } + +@Preview +@Composable +private fun ToolbarPreview() { + Toolbar( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.appColors.background) + .height(48.dp), + label = "Toolbar", + canShowBackBtn = true, + onBackClick = {} + ) +} + +@Preview +@Composable +private fun AuthButtonsPanelPreview() { + AuthButtonsPanel(onRegisterClick = {}, onSignInClick = {}) +} diff --git a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt index 424bf6066..0cfa9c57c 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -1,25 +1,42 @@ package org.openedx.core.ui +import android.content.res.Configuration import android.graphics.Rect import android.view.ViewTreeObserver +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.* +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalView -import org.openedx.core.presentation.global.InsetHolder +import androidx.compose.ui.unit.Dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch +import org.openedx.core.presentation.global.InsetHolder inline val isPreview: Boolean @ReadOnlyComposable @@ -59,6 +76,16 @@ fun Modifier.statusBarsInset(): Modifier = composed { .padding(top = with(LocalDensity.current) { topInset.toDp() }) } +fun Modifier.displayCutoutForLandscape(): Modifier = composed { + val cutoutInset = (LocalContext.current as? InsetHolder)?.cutoutInset ?: 0 + val cutoutInsetDp = with(LocalDensity.current) { cutoutInset.toDp() } + return@composed if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) { + this.padding(horizontal = cutoutInsetDp) + } else { + this + } +} + inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier = composed { clickable( indication = null, @@ -67,6 +94,35 @@ inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier } } +fun Modifier.roundBorderWithoutBottom(borderWidth: Dp, cornerRadius: Dp): Modifier = composed( + factory = { + var path: Path + this.then( + Modifier.drawWithCache { + val height = this.size.height + val width = this.size.width + onDrawWithContent { + drawContent() + path = Path().apply { + moveTo(width.times(0f), height.times(1f)) + lineTo(width.times(0f), height.times(0f)) + lineTo(width.times(1f), height.times(0f)) + lineTo(width.times(1f), height.times(1f)) + } + drawPath( + path = path, + color = Color.LightGray, + style = Stroke( + width = borderWidth.toPx(), + pathEffect = PathEffect.cornerPathEffect(cornerRadius.toPx()) + ) + ) + } + } + ) + } +) + @Composable fun rememberSaveableMap(init: () -> MutableMap): MutableMap { return rememberSaveable( @@ -119,3 +175,7 @@ fun LazyListState.reEnableScrolling(scope: CoroutineScope) { } } +@OptIn(ExperimentalFoundationApi::class) +fun PagerState.calculateCurrentOffsetForPage(page: Int): Float { + return (currentPage - page) + currentPageOffsetFraction +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt new file mode 100644 index 000000000..e58879326 --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -0,0 +1,213 @@ +package org.openedx.core.ui + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.zIndex +import org.openedx.core.extension.isEmailValid +import org.openedx.core.extension.replaceLinkTags +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.EmailUtil +import java.nio.charset.StandardCharsets + +@Composable +fun WebContentScreen( + windowSize: WindowSize, + apiHostUrl: String? = null, + title: String, + onBackClick: () -> Unit, + htmlBody: String? = null, + contentUrl: String? = null, +) { + val scaffoldState = rememberScaffoldState() + Scaffold( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 16.dp), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + + val screenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(it) + .statusBarsInset() + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column(screenWidth) { + Box( + Modifier + .fillMaxWidth() + .zIndex(1f), + contentAlignment = Alignment.CenterStart + ) { + BackBtn { + onBackClick() + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 56.dp), + text = title, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.height(6.dp)) + Surface( + Modifier.fillMaxSize(), + color = MaterialTheme.appColors.background + ) { + if (htmlBody.isNullOrEmpty() && contentUrl.isNullOrEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.appColors.background) + .zIndex(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else { + var webViewAlpha by rememberSaveable { mutableFloatStateOf(0f) } + Surface( + Modifier + .padding(horizontal = 16.dp, vertical = 24.dp) + .alpha(webViewAlpha), + color = MaterialTheme.appColors.background + ) { + WebViewContent( + apiHostUrl = apiHostUrl, + body = htmlBody, + contentUrl = contentUrl, + onWebPageLoaded = { + webViewAlpha = 1f + }) + } + } + } + } + } + } +} + +@Composable +@SuppressLint("SetJavaScriptEnabled") +private fun WebViewContent( + apiHostUrl: String? = null, + body: String? = null, + contentUrl: String? = null, + onWebPageLoaded: () -> Unit +) { + val context = LocalContext.current + val isDarkTheme = isSystemInDarkTheme() + AndroidView( + factory = { + WebView(context).apply { + webViewClient = object : WebViewClient() { + override fun onPageCommitVisible(view: WebView?, url: String?) { + super.onPageCommitVisible(view, url) + onWebPageLoaded() + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + val clickUrl = request?.url?.toString() ?: "" + return if (clickUrl.isNotEmpty() && + (clickUrl.startsWith("http://") || + clickUrl.startsWith("https://")) + ) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(clickUrl))) + true + } else if (clickUrl.startsWith("mailto:")) { + val email = clickUrl.replace("mailto:", "") + if (email.isEmailValid()) { + EmailUtil.sendEmailIntent(context, email, "", "") + true + } else { + false + } + } else { + false + } + } + } + with(settings) { + javaScriptEnabled = true + loadWithOverviewMode = true + builtInZoomControls = false + setSupportZoom(true) + loadsImagesAutomatically = true + domStorageEnabled = true + } + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + body?.let { + loadDataWithBaseURL( + apiHostUrl, + body.replaceLinkTags(isDarkTheme), + "text/html", + StandardCharsets.UTF_8.name(), + null + ) + } + contentUrl?.let { + loadUrl(it) + } + } + }, + ) +} diff --git a/core/src/main/java/org/openedx/core/ui/theme/Color.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt similarity index 71% rename from core/src/main/java/org/openedx/core/ui/theme/Color.kt rename to core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 4dedf421c..23d82a4c6 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Color.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -29,7 +29,26 @@ data class AppColors( val certificateForeground: Color, val bottomSheetToggle: Color, val warning: Color, - val info: Color + val info: Color, + + val rateStars: Color, + val inactiveButtonBackground: Color, + val inactiveButtonText: Color, + + val accessGreen: Color, + + val datesSectionBarPastDue: Color, + val datesSectionBarToday: Color, + val datesSectionBarThisWeek: Color, + val datesSectionBarNextWeek: Color, + val datesSectionBarUpcoming: Color, + + val authFacebookButtonBackground: Color, + val authMicrosoftButtonBackground: Color, + + val componentHorizontalProgressCompleted: Color, + val componentHorizontalProgressSelected: Color, + val componentHorizontalProgressDefault: Color, ) { val primary: Color get() = material.primary val primaryVariant: Color get() = material.primaryVariant diff --git a/core/src/main/java/org/openedx/core/ui/theme/Shape.kt b/core/src/main/java/org/openedx/core/ui/theme/Shape.kt index 0cb58604f..eed4d481d 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Shape.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Shape.kt @@ -1,17 +1,10 @@ package org.openedx.core.ui.theme import androidx.compose.foundation.shape.CornerBasedShape -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Shapes import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.geometry.* -import androidx.compose.ui.graphics.Outline -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp data class AppShapes( val material: Shapes, @@ -22,27 +15,9 @@ data class AppShapes( val cardShape: CornerBasedShape, val screenBackgroundShapeFull: CornerBasedShape, val courseImageShape: CornerBasedShape, - val dialogShape: CornerBasedShape + val dialogShape: CornerBasedShape, ) -internal val LocalShapes = staticCompositionLocalOf { - AppShapes( - material = Shapes( - small = RoundedCornerShape(4.dp), - medium = RoundedCornerShape(8.dp), - large = RoundedCornerShape(0.dp) - ), - buttonShape = RoundedCornerShape(8.dp), - navigationButtonShape = RoundedCornerShape(8.dp), - textFieldShape = RoundedCornerShape(CornerSize(8.dp)), - screenBackgroundShape = RoundedCornerShape(topStart = 30.dp, topEnd = 30.dp), - cardShape = RoundedCornerShape(12.dp), - screenBackgroundShapeFull = RoundedCornerShape(24.dp), - courseImageShape = RoundedCornerShape(8.dp), - dialogShape = RoundedCornerShape(24.dp) - ) -} - val MaterialTheme.appShapes: AppShapes @Composable @ReadOnlyComposable diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index a468ebb72..fbe6d80b5 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -9,90 +9,127 @@ import androidx.compose.material.lightColors import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.ui.graphics.Color private val DarkColorPalette = AppColors( material = darkColors( - primary = Color(0xFF5478F9), - primaryVariant = Color(0xFF3700B3), - secondary = Color(0xFF03DAC6), - secondaryVariant = Color(0xFF373E4F), - background = Color(0xFF19212F), - surface = Color(0xFF273346), - error = Color(0xFFFF3D71), - onPrimary = Color.Black, - onSecondary = Color.Black, - onBackground = Color.White, - onSurface = Color.White, - onError = Color.Black + primary = dark_primary, + primaryVariant = dark_primary_variant, + secondary = dark_secondary, + secondaryVariant = dark_secondary_variant, + background = dark_background, + surface = dark_surface, + error = dark_error, + onPrimary = dark_onPrimary, + onSecondary = dark_onSecondary, + onBackground = dark_onBackground, + onSurface = dark_onSurface, + onError = dark_onError ), - textPrimary = Color.White, - textPrimaryVariant = Color(0xFF79889F), - textSecondary = Color(0xFFB3B3B3), - textDark = Color(0xFF19212F), - textAccent = Color(0xFF5478F9), - - textFieldBackground = Color(0xFF273346), - textFieldBackgroundVariant = Color(0xFF273346), - textFieldBorder = Color(0xFF4E5A70), - textFieldText = Color.White, - textFieldHint = Color(0xFF79889F), - - buttonBackground = Color(0xFF5478F9), - buttonSecondaryBackground = Color(0xFF79889F), - buttonText = Color.White, - - cardViewBackground = Color(0xFF273346), - cardViewBorder = Color(0xFF4E5A70), - divider = Color(0xFF4E5A70), - - certificateForeground = Color(0xD92EB865), - bottomSheetToggle = Color(0xFF4E5A70), - - warning = Color(0xFFFFC248), - info = Color(0xFF0095FF) + textPrimary = dark_text_primary, + textPrimaryVariant = dark_text_primary_variant, + textSecondary = dark_text_secondary, + textDark = dark_text_dark, + textAccent = dark_text_accent, + + textFieldBackground = dark_text_field_background, + textFieldBackgroundVariant = dark_text_field_background_variant, + textFieldBorder = dark_text_field_border, + textFieldText = dark_text_field_text, + textFieldHint = dark_text_field_hint, + + buttonBackground = dark_button_background, + buttonSecondaryBackground = dark_button_secondary_background, + buttonText = dark_button_text, + + cardViewBackground = dark_card_view_background, + cardViewBorder = dark_card_view_border, + divider = dark_divider, + + certificateForeground = dark_certificate_foreground, + bottomSheetToggle = dark_bottom_sheet_toggle, + + warning = dark_warning, + info = dark_info, + + rateStars = dark_rate_stars, + inactiveButtonBackground = dark_inactive_button_background, + inactiveButtonText = dark_button_text, + + accessGreen = dark_access_green, + + datesSectionBarPastDue = dark_dates_section_bar_past_due, + datesSectionBarToday = dark_dates_section_bar_today, + datesSectionBarThisWeek = dark_dates_section_bar_this_week, + datesSectionBarNextWeek = dark_dates_section_bar_next_week, + datesSectionBarUpcoming = dark_dates_section_bar_upcoming, + + authFacebookButtonBackground = dark_auth_facebook_button_background, + authMicrosoftButtonBackground = dark_auth_microsoft_button_background, + + componentHorizontalProgressCompleted = dark_component_horizontal_progress_completed, + componentHorizontalProgressSelected = dark_component_horizontal_progress_selected, + componentHorizontalProgressDefault = dark_component_horizontal_progress_default, ) private val LightColorPalette = AppColors( material = lightColors( - primary = Color(0xFF3C68FF), - primaryVariant = Color(0x9ADEFAFF), - secondary = Color(0xFF94D3DD), - secondaryVariant = Color(0xFF94D3DD), - background = Color.White, - surface = Color(0xFFF7F7F8), - error = Color(0xFFFF3D71), - onPrimary = Color.White, - onSecondary = Color.Black, - onBackground = Color.Black, - onSurface = Color.Black, - onError = Color.White + primary = light_primary, + primaryVariant = light_primary_variant, + secondary = light_secondary, + secondaryVariant = light_secondary_variant, + background = light_background, + surface = light_surface, + error = light_error, + onPrimary = light_onPrimary, + onSecondary = light_onSecondary, + onBackground = light_onBackground, + onSurface = light_onSurface, + onError = light_onError ), - textPrimary = Color(0xFF212121), - textPrimaryVariant = Color(0xFF3D4964), - textSecondary = Color(0xFFB3B3B3), - textDark = Color(0xFF19212F), - textAccent = Color(0xFF3C68FF), - - textFieldBackground = Color(0xFFF7F7F8), - textFieldBackgroundVariant = Color.White, - textFieldBorder = Color(0xFF97A5BB), - textFieldText = Color(0xFF3D4964), - textFieldHint = Color(0xFF97A5BB), - - buttonBackground = Color(0xFF3C68FF), - buttonSecondaryBackground = Color(0xFF79889F), - buttonText = Color.White, - - cardViewBackground = Color(0xFFF9FAFB), - cardViewBorder = Color(0xFFCCD4E0), - divider = Color(0xFFCCD4E0), - - certificateForeground = Color(0xD94BD191), - bottomSheetToggle = Color(0xFF4E5A70), - - warning = Color(0xFFFFC94D), - info = Color(0xFF42AAFF) + textPrimary = light_text_primary, + textPrimaryVariant = light_text_primary_variant, + textSecondary = light_text_secondary, + textDark = light_text_dark, + textAccent = light_text_accent, + + textFieldBackground = light_text_field_background, + textFieldBackgroundVariant = light_text_field_background_variant, + textFieldBorder = light_text_field_border, + textFieldText = light_text_field_text, + textFieldHint = light_text_field_hint, + + buttonBackground = light_button_background, + buttonSecondaryBackground = light_button_secondary_background, + buttonText = light_button_text, + + cardViewBackground = light_card_view_background, + cardViewBorder = light_card_view_border, + divider = light_divider, + + certificateForeground = light_certificate_foreground, + bottomSheetToggle = light_bottom_sheet_toggle, + + warning = light_warning, + info = light_info, + + rateStars = light_rate_stars, + inactiveButtonBackground = light_inactive_button_background, + inactiveButtonText = light_button_text, + + accessGreen = light_access_green, + + datesSectionBarPastDue = light_dates_section_bar_past_due, + datesSectionBarToday = light_dates_section_bar_today, + datesSectionBarThisWeek = light_dates_section_bar_this_week, + datesSectionBarNextWeek = light_dates_section_bar_next_week, + datesSectionBarUpcoming = light_dates_section_bar_upcoming, + + authFacebookButtonBackground = light_auth_facebook_button_background, + authMicrosoftButtonBackground = light_auth_microsoft_button_background, + + componentHorizontalProgressCompleted = light_component_horizontal_progress_completed, + componentHorizontalProgressSelected = light_component_horizontal_progress_selected, + componentHorizontalProgressDefault = light_component_horizontal_progress_default, ) val MaterialTheme.appColors: AppColors diff --git a/core/src/main/java/org/openedx/core/ui/theme/Type.kt b/core/src/main/java/org/openedx/core/ui/theme/Type.kt index 42721e5de..edd2afcc7 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Type.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Type.kt @@ -5,10 +5,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +import org.openedx.core.R data class AppTypography( + val defaultFontFamily: FontFamily, val displayLarge: TextStyle, val displayMedium: TextStyle, val displaySmall: TextStyle, @@ -26,7 +31,17 @@ data class AppTypography( val labelSmall: TextStyle ) - +val fontFamily = FontFamily( + Font(R.font.regular, FontWeight.Black, FontStyle.Normal), + Font(R.font.bold, FontWeight.Bold, FontStyle.Normal), + Font(R.font.bold, FontWeight.Bold, FontStyle.Normal), + Font(R.font.extra_light, FontWeight.Light, FontStyle.Normal), + Font(R.font.light, FontWeight.Light, FontStyle.Normal), + Font(R.font.medium, FontWeight.Medium, FontStyle.Normal), + Font(R.font.regular, FontWeight.Normal, FontStyle.Normal), + Font(R.font.semi_bold, FontWeight.Bold, FontStyle.Normal), + Font(R.font.thin, FontWeight.Thin, FontStyle.Normal), +) internal val LocalTypography = staticCompositionLocalOf { @@ -35,92 +50,108 @@ internal val LocalTypography = staticCompositionLocalOf { fontSize = 57.sp, lineHeight = 64.sp, fontWeight = FontWeight.Normal, - letterSpacing = (-0.25).sp + letterSpacing = (-0.25).sp, + fontFamily = fontFamily ), displayMedium = TextStyle( fontSize = 45.sp, lineHeight = 52.sp, fontWeight = FontWeight.Normal, - letterSpacing = 0.sp + letterSpacing = 0.sp, + fontFamily = fontFamily ), displaySmall = TextStyle( fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight.Bold, - letterSpacing = 0.sp + letterSpacing = 0.sp, + fontFamily = fontFamily ), headlineLarge = TextStyle( fontSize = 32.sp, lineHeight = 40.sp, fontWeight = FontWeight.Normal, - letterSpacing = 0.sp + letterSpacing = 0.sp, + fontFamily = fontFamily ), headlineMedium = TextStyle( fontSize = 28.sp, lineHeight = 36.sp, fontWeight = FontWeight.Normal, - letterSpacing = 0.sp + letterSpacing = 0.sp, + fontFamily = fontFamily ), headlineSmall = TextStyle( fontSize = 24.sp, lineHeight = 32.sp, fontWeight = FontWeight.SemiBold, - letterSpacing = 0.sp + letterSpacing = 0.sp, + fontFamily = fontFamily ), titleLarge = TextStyle( fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, - letterSpacing = 0.sp + letterSpacing = 0.sp, + fontFamily = fontFamily ), titleMedium = TextStyle( fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold, - letterSpacing = 0.1.sp + letterSpacing = 0.1.sp, + fontFamily = fontFamily ), titleSmall = TextStyle( fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium, - letterSpacing = 0.1.sp + letterSpacing = 0.1.sp, + fontFamily = fontFamily ), bodyLarge = TextStyle( fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.Normal, - letterSpacing = 0.5.sp + letterSpacing = 0.5.sp, + fontFamily = fontFamily ), bodyMedium = TextStyle( fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal, - letterSpacing = 0.25.sp + letterSpacing = 0.25.sp, + fontFamily = fontFamily ), bodySmall = TextStyle( fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.Normal, - letterSpacing = 0.4.sp + letterSpacing = 0.4.sp, + fontFamily = fontFamily ), labelLarge = TextStyle( fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium, - letterSpacing = 0.1.sp + letterSpacing = 0.1.sp, + fontFamily = fontFamily ), labelMedium = TextStyle( fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.Normal, - letterSpacing = 0.5.sp + letterSpacing = 0.5.sp, + fontFamily = fontFamily ), labelSmall = TextStyle( fontSize = 10.sp, lineHeight = 16.sp, fontWeight = FontWeight.Normal, - letterSpacing = 0.sp - ) + letterSpacing = 0.sp, + fontFamily = fontFamily + ), + defaultFontFamily = fontFamily ) } diff --git a/core/src/main/java/org/openedx/core/utils/EmailUtil.kt b/core/src/main/java/org/openedx/core/utils/EmailUtil.kt index 2e78934bb..c56b606ae 100644 --- a/core/src/main/java/org/openedx/core/utils/EmailUtil.kt +++ b/core/src/main/java/org/openedx/core/utils/EmailUtil.kt @@ -3,6 +3,7 @@ package org.openedx.core.utils import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build import android.widget.Toast import org.openedx.core.R @@ -11,23 +12,24 @@ object EmailUtil { fun showFeedbackScreen( context: Context, - subject: String, + feedbackEmailAddress: String, + subject: String = context.getString(R.string.core_email_subject), + feedback: String = "", appVersion: String ) { val NEW_LINE = "\n" - val to = context.getString(R.string.feedback_email_address) val body = StringBuilder() with(body) { + append(feedback) + append(NEW_LINE) + append(NEW_LINE) append("${context.getString(R.string.core_android_os_version)} ${Build.VERSION.RELEASE}") append(NEW_LINE) append("${context.getString(R.string.core_app_version)} $appVersion") append(NEW_LINE) append("${context.getString(R.string.core_android_device_model)} ${Build.MODEL}") - append(NEW_LINE) - append(NEW_LINE) - append(context.getString(R.string.core_insert_feedback)) } - sendEmailIntent(context, to, subject, body.toString()) + sendEmailIntent(context, feedbackEmailAddress, subject, body.toString()) } fun sendEmailIntent( @@ -36,11 +38,12 @@ object EmailUtil { subject: String, email: String ) { - val emailIntent = Intent(Intent.ACTION_SEND) - emailIntent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) - emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject) - emailIntent.putExtra(Intent.EXTRA_TEXT, email) - emailIntent.type = "plain/text" + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:") + putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, email) + } try { emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context?.let { diff --git a/core/src/main/java/org/openedx/core/utils/Logger.kt b/core/src/main/java/org/openedx/core/utils/Logger.kt new file mode 100644 index 000000000..f6bb4ecb0 --- /dev/null +++ b/core/src/main/java/org/openedx/core/utils/Logger.kt @@ -0,0 +1,18 @@ +package org.openedx.core.utils + +import android.util.Log +import org.openedx.core.BuildConfig + +class Logger(private val tag: String) { + fun d(message: () -> String) { + if (BuildConfig.DEBUG) Log.d(tag, message()) + } + + fun e(message: () -> String) { + if (BuildConfig.DEBUG) Log.e(tag, message()) + } + + fun w(message: () -> String) { + if (BuildConfig.DEBUG) Log.w(tag, message()) + } +} diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index bf30e40e2..e85397491 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -2,34 +2,54 @@ package org.openedx.core.utils import android.content.Context import android.text.format.DateUtils +import com.google.gson.internal.bind.util.ISO8601Utils import org.openedx.core.R import org.openedx.core.domain.model.StartType import org.openedx.core.system.ResourceManager import java.text.ParseException +import java.text.ParsePosition import java.text.SimpleDateFormat -import java.util.* +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.math.ceil object TimeUtils { private const val FORMAT_ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'" - private const val FORMAT_APPLICATION = "dd.MM.yyyy HH:mm" - private const val FORMAT_DATE = "dd MMM, yyyy" + private const val FORMAT_ISO_8601_WITH_TIME_ZONE = "yyyy-MM-dd'T'HH:mm:ssXXX" private const val SEVEN_DAYS_IN_MILLIS = 604800000L + fun getCurrentTime(): Long { + return Calendar.getInstance().timeInMillis + } + fun iso8601ToDate(text: String): Date? { return try { - val sdf = SimpleDateFormat(FORMAT_ISO_8601, Locale.getDefault()) + val parsePosition = ParsePosition(0) + return ISO8601Utils.parse(text, parsePosition) + } catch (e: ParseException) { + null + } + } + + fun iso8601WithTimeZoneToDate(text: String): Date? { + return try { + val sdf = SimpleDateFormat(FORMAT_ISO_8601_WITH_TIME_ZONE, Locale.getDefault()) sdf.parse(text) } catch (e: ParseException) { null } } - fun iso8601ToDateWithTime(context: Context,text: String): String { + fun iso8601ToDateWithTime(context: Context, text: String): String { return try { val courseDateFormat = SimpleDateFormat(FORMAT_ISO_8601, Locale.getDefault()) - val applicationDateFormat = SimpleDateFormat(context.getString(R.string.core_full_date_with_time), Locale.getDefault()) + val applicationDateFormat = SimpleDateFormat( + context.getString(R.string.core_full_date_with_time), Locale.getDefault() + ) applicationDateFormat.format(courseDateFormat.parse(text)!!) } catch (e: Exception) { e.printStackTrace() @@ -37,14 +57,17 @@ object TimeUtils { } } - fun dateToCourseDate(resourceManager: ResourceManager, date: Date?): String { + private fun dateToCourseDate(resourceManager: ResourceManager, date: Date?): String { + return formatDate( + format = resourceManager.getString(R.string.core_date_format_MMMM_dd), date = date + ) + } + + private fun formatDate(format: String, date: Date?): String { if (date == null) { return "" } - val sdf = SimpleDateFormat( - resourceManager.getString(R.string.core_date_format_MMMM_dd), - Locale.getDefault() - ) + val sdf = SimpleDateFormat(format, Locale.getDefault()) return sdf.format(date) } @@ -56,7 +79,7 @@ object TimeUtils { * @return true if the other date is past today, * false otherwise. */ - fun isDatePassed(today: Date, otherDate: Date?): Boolean { + private fun isDatePassed(today: Date, otherDate: Date?): Boolean { return otherDate != null && today.after(otherDate) } @@ -69,7 +92,7 @@ object TimeUtils { startType: String, startDisplay: String ): String { - var formattedDate = "" + val formattedDate: String val resourceManager = ResourceManager(context) if (isDatePassed(today, start)) { @@ -88,8 +111,10 @@ object TimeUtils { ) } else { val timeSpan = DateUtils.getRelativeTimeSpanString( - expiry.time, today.time, - DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE + expiry.time, + today.time, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE ).toString() resourceManager.getString(R.string.core_label_expired, timeSpan) } @@ -101,8 +126,10 @@ object TimeUtils { ) } else { val timeSpan = DateUtils.getRelativeTimeSpanString( - expiry.time, today.time, - DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE + expiry.time, + today.time, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE ).toString() resourceManager.getString(R.string.core_label_expires, timeSpan) } @@ -121,13 +148,11 @@ object TimeUtils { } } else if (isDatePassed(today, end)) { resourceManager.getString( - R.string.core_label_ended, - dateToCourseDate(resourceManager, end) + R.string.core_label_ended, dateToCourseDate(resourceManager, end) ) } else { resourceManager.getString( - R.string.core_label_ending, - dateToCourseDate(resourceManager, end) + R.string.core_label_ending, dateToCourseDate(resourceManager, end) ) } } @@ -146,4 +171,137 @@ object TimeUtils { return formattedDate } -} \ No newline at end of file + /** + * Method to get the formatted time string in terms of relative time with minimum resolution of minutes. + * For example, if the time difference is 1 minute, it will return "1m ago". + * + * @param date Date object to be formatted. + */ + fun getFormattedTime(date: Date): String { + return DateUtils.getRelativeTimeSpanString( + date.time, + getCurrentTime(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_TIME + ).toString() + } + + /** + * Returns a formatted date string for the given date. + */ + fun getCourseFormattedDate(context: Context, date: Date): String { + val inputDate = Calendar.getInstance().also { + it.time = date + it.clearTimeComponents() + } + val daysDifference = getDayDifference(inputDate) + + return when { + daysDifference == 0 -> { + context.getString(R.string.core_date_format_today) + } + + daysDifference == 1 -> { + context.getString(R.string.core_date_format_tomorrow) + } + + daysDifference == -1 -> { + context.getString(R.string.core_date_format_yesterday) + } + + daysDifference in -2 downTo -7 -> { + context.getString( + R.string.core_date_format_days_ago, + ceil(-daysDifference.toDouble()).toInt().toString() + ) + } + + daysDifference in 2..7 -> { + DateUtils.formatDateTime( + context, + date.time, + DateUtils.FORMAT_SHOW_WEEKDAY + ) + } + + inputDate.get(Calendar.YEAR) != Calendar.getInstance().get(Calendar.YEAR) -> { + DateUtils.formatDateTime( + context, + date.time, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR + ) + } + + else -> { + DateUtils.formatDateTime( + context, + date.time, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NO_YEAR + ) + } + } + } + + /** + * Returns the number of days difference between the given date and the current date. + */ + private fun getDayDifference(inputDate: Calendar): Int { + val currentDate = Calendar.getInstance().also { it.clearTimeComponents() } + val difference = inputDate.timeInMillis - currentDate.timeInMillis + return TimeUnit.MILLISECONDS.toDays(difference).toInt() + } +} + +/** + * Extension function to clear time components of a calendar. + * for example, if the time is 10:30:45, it will set the time to 00:00:00 + */ +fun Calendar.clearTimeComponents() { + this.set(Calendar.HOUR_OF_DAY, 0) + this.set(Calendar.MINUTE, 0) + this.set(Calendar.SECOND, 0) + this.set(Calendar.MILLISECOND, 0) +} + +/** + * Extension function to check if the given date is today. + */ +fun Date.isToday(): Boolean { + val calendar = Calendar.getInstance() + calendar.time = this + calendar.clearTimeComponents() + return calendar.time == Date().clearTime() +} + +/** + * Extension function to add days to a date. + * for example, if the date is 2020-01-01 10:30:45, and days is 2, it will return 2020-01-03 00:00:00 + */ +fun Date.addDays(days: Int): Date { + val calendar = Calendar.getInstance() + calendar.time = this + calendar.clearTimeComponents() + calendar.add(Calendar.DATE, days) + return calendar.time +} + +/** + * Extension function to clear time components of a date. + * for example, if the date is 2020-01-01 10:30:45, it will return 2020-01-01 00:00:00 + */ +fun Date.clearTime(): Date { + val calendar = Calendar.getInstance() + calendar.time = this + calendar.clearTimeComponents() + return calendar.time +} + +/** + * Extension function to check if the time difference between the given date and the current date is less than 24 hours. + */ +fun Date.isTimeLessThan24Hours(): Boolean { + val calendar = Calendar.getInstance() + calendar.time = this + val timeInMillis = (calendar.timeInMillis - TimeUtils.getCurrentTime()).unaryPlus() + return timeInMillis < TimeUnit.DAYS.toMillis(1) +} diff --git a/core/src/main/java/org/openedx/core/utils/UrlUtils.kt b/core/src/main/java/org/openedx/core/utils/UrlUtils.kt new file mode 100644 index 000000000..e90f0dfae --- /dev/null +++ b/core/src/main/java/org/openedx/core/utils/UrlUtils.kt @@ -0,0 +1,26 @@ +package org.openedx.core.utils + +import android.content.Context +import android.content.Intent +import android.net.Uri + +object UrlUtils { + fun openInBrowser(activity: Context, apiHostUrl: String, url: String) { + if (url.isEmpty()) { + return + } + if (url.startsWith("/")) { + // Use API host as the base URL for relative paths + val absoluteUrl = "$apiHostUrl$url" + openInBrowser(activity, absoluteUrl) + return + } + openInBrowser(activity, url) + } + + private fun openInBrowser(context: Context, url: String) { + val intent = Intent(Intent.ACTION_VIEW) + intent.setData(Uri.parse(url)) + context.startActivity(intent) + } +} diff --git a/core/src/main/res/drawable/core_ic_assignment.xml b/core/src/main/res/drawable/core_ic_assignment.xml new file mode 100644 index 000000000..fdd07fd45 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_assignment.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/core/src/main/res/drawable/core_ic_back.xml b/core/src/main/res/drawable/core_ic_back.xml index 14528269e..912dc1200 100644 --- a/core/src/main/res/drawable/core_ic_back.xml +++ b/core/src/main/res/drawable/core_ic_back.xml @@ -11,21 +11,21 @@ android:strokeLineJoin="round" android:strokeWidth="1.75" android:fillColor="#00000000" - android:strokeColor="#19212F" + android:strokeColor="#ffffff" android:strokeLineCap="round"/> diff --git a/core/src/main/res/drawable/core_ic_calendar.xml b/core/src/main/res/drawable/core_ic_calendar.xml new file mode 100644 index 000000000..8dcd8c896 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_calendar.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/drawable/core_ic_certificate.xml b/core/src/main/res/drawable/core_ic_certificate.xml new file mode 100644 index 000000000..c090f12e3 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_certificate.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/drawable/core_ic_check.xml b/core/src/main/res/drawable/core_ic_check.xml index 6602a321d..81badcbcd 100644 --- a/core/src/main/res/drawable/core_ic_check.xml +++ b/core/src/main/res/drawable/core_ic_check.xml @@ -1,17 +1,13 @@ - - - - + android:width="18dp" + android:height="12dp" + android:viewportWidth="18" + android:viewportHeight="12"> + diff --git a/core/src/main/res/drawable/core_ic_check_in_box.xml b/core/src/main/res/drawable/core_ic_check_in_box.xml new file mode 100644 index 000000000..6602a321d --- /dev/null +++ b/core/src/main/res/drawable/core_ic_check_in_box.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/core/src/main/res/drawable/core_ic_course_expire.xml b/core/src/main/res/drawable/core_ic_course_expire.xml new file mode 100644 index 000000000..f9e6a11ad --- /dev/null +++ b/core/src/main/res/drawable/core_ic_course_expire.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/src/main/res/drawable/core_ic_heart.xml b/core/src/main/res/drawable/core_ic_heart.xml new file mode 100644 index 000000000..63af69c53 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_heart.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core/src/main/res/drawable/core_ic_icon_upgrade.xml b/core/src/main/res/drawable/core_ic_icon_upgrade.xml new file mode 100644 index 000000000..6e3ffa576 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_icon_upgrade.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/core/src/main/res/drawable/core_ic_lock.xml b/core/src/main/res/drawable/core_ic_lock.xml new file mode 100644 index 000000000..d0e0ba4c7 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/drawable/core_ic_start_end.xml b/core/src/main/res/drawable/core_ic_start_end.xml new file mode 100644 index 000000000..6cf542287 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_start_end.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/drawable/core_ic_warning.xml b/core/src/main/res/drawable/core_ic_warning.xml new file mode 100644 index 000000000..7270dc5a9 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_warning.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/src/main/res/font/black.ttf b/core/src/main/res/font/black.ttf new file mode 100644 index 000000000..5aecf7dc4 Binary files /dev/null and b/core/src/main/res/font/black.ttf differ diff --git a/core/src/main/res/font/bold.ttf b/core/src/main/res/font/bold.ttf new file mode 100644 index 000000000..8e82c70d1 Binary files /dev/null and b/core/src/main/res/font/bold.ttf differ diff --git a/core/src/main/res/font/extra_bold.ttf b/core/src/main/res/font/extra_bold.ttf new file mode 100644 index 000000000..cb4b8217f Binary files /dev/null and b/core/src/main/res/font/extra_bold.ttf differ diff --git a/core/src/main/res/font/extra_light.ttf b/core/src/main/res/font/extra_light.ttf new file mode 100644 index 000000000..64aee30a4 Binary files /dev/null and b/core/src/main/res/font/extra_light.ttf differ diff --git a/core/src/main/res/font/font.xml b/core/src/main/res/font/font.xml new file mode 100644 index 000000000..4cdad3af5 --- /dev/null +++ b/core/src/main/res/font/font.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/main/res/font/light.ttf b/core/src/main/res/font/light.ttf new file mode 100644 index 000000000..9e265d890 Binary files /dev/null and b/core/src/main/res/font/light.ttf differ diff --git a/core/src/main/res/font/medium.ttf b/core/src/main/res/font/medium.ttf new file mode 100644 index 000000000..b53fb1c4a Binary files /dev/null and b/core/src/main/res/font/medium.ttf differ diff --git a/core/src/main/res/font/regular.ttf b/core/src/main/res/font/regular.ttf new file mode 100644 index 000000000..8d4eebf20 Binary files /dev/null and b/core/src/main/res/font/regular.ttf differ diff --git a/core/src/main/res/font/semi_bold.ttf b/core/src/main/res/font/semi_bold.ttf new file mode 100644 index 000000000..c6aeeb16a Binary files /dev/null and b/core/src/main/res/font/semi_bold.ttf differ diff --git a/core/src/main/res/font/thin.ttf b/core/src/main/res/font/thin.ttf new file mode 100644 index 000000000..7aed55d56 Binary files /dev/null and b/core/src/main/res/font/thin.ttf differ diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index 8de28cc5b..827211c40 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -21,7 +21,7 @@ Пароль незабаром Авто (Рекомендовано) - 360p (Найменший розмір) + 360p (Менше використання трафіку) 540p 720p (Найкраща якість) Офлайн @@ -31,13 +31,38 @@ Обліковий запис користувача не активовано. Будь ласка, спочатку активуйте свій обліковий запис. Надіслати електронний лист за допомогою ... Не встановлено жодного поштового клієнта - Версія додатку: - Android OS: - Модель пристрою: - Відгук: - Відгук клієнта - dd MMMM dd MMM yyyy HH:mm + Оновлення додатку + Ми рекомендуємо вам оновитись до останньої версії. Оновіться зараз, щоб отримати останні функції та виправлення. + Доступне нове оновлення! Оновіть зараз, щоб отримати останні можливості та виправлення + Не зараз + Оновити + Застаріла версія додатку + Налаштування аккаунту + Необхідне оновлення додатку + Ця версія додатка %1$s застаріла. Щоб продовжити навчання та отримати останні можливості та виправлення, будь ласка, оновіть до останньої версії. + Чому мені потрібно оновити? + Версія: %1$s + Оновлено + Натисніть, щоб оновити до версії %1$s + Натисніть, щоб встановити обов\'язкове оновлення додатку + Підтвердити + Вам подобається %1$s? + Ваш відгук має значення для нас. Будь ласка, оцініть додаток, натиснувши на зірочку нижче. Дякуємо за вашу підтримку! + Залиште відгук + Нам шкода, що ваш досвід навчання був з деякими проблемами. Ми цінуємо всі відгуки. + Що могло б бути краще? + Поділитися відгуком + Дякуємо + Оцінити нас + Дякуємо за надання відгуку. Чи бажаєте ви поділитися своєю оцінкою цього додатка з іншими користувачами в магазині додатків? + Ми отримали ваш відгук і використовуватимемо його, щоб покращити ваш досвід навчання в майбутньому. Дякуємо, що поділилися! + Ви не підключені до Інтернету. Будь ласка, перевірте ваше підключення до Інтернету. + + Зареєструватися + Увійти - \ No newline at end of file + + %1$s зображення профілю + diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 6c2620495..6b77b5b11 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - - OpenEdX + + @string/platform_name Results Invalid credentials @@ -8,6 +8,9 @@ Something went wrong Try again Privacy policy + Cookie policy + Do not sell my personal information + View FAQ Terms of use Profile Cancel @@ -27,18 +30,83 @@ Reload Downloading in progress Auto (Recommended) - 360p (Smallest file size) + 360p (Lower data usage) 540p 720p (Best quality) User account is not activated. Please activate your account first. - Send email using... + Send email using… No e-mail clients installed - App Version: - Android OS: - Device Model: - Feedback: - Customer Feedback - + App version: + OS version: + Device model: + Feedback MMMM dd dd MMM yyyy hh:mm aaa - \ No newline at end of file + App Update + We recommend that you update to the latest version. Upgrade now to receive the latest features and fixes. + New update available! Upgrade now to receive the latest features and fixes + Not Now + Update + Deprecated App Version + Account Settings + App Update Required + This version of the OpenEdX app is out-of-date. To continue learning and get the latest features and fixes, please upgrade to the latest version. + Why do I need to update? + Version: %1$s + Up-to-date + Tap to update to version %1$s + Tap to install required app update + Submit + Enjoying %1$s? + Your feedback matters to us. Would you take a moment to rate the app by tapping a star below? Thanks for your support! + Leave Us Feedback + We’re sorry to hear your learning experience has had some issues. We appreciate all feedback. + What could have been better? + Share Feedback + Thank You + Rate Us + Thank you for sharing your feedback with us. Would you like to share your review of this app with other users on the app store? + We received your feedback and will use it to help improve your learning experience going forward. Thank you for sharing! + You are not connected to the Internet. Please check your Internet connection. + OK + Continue + Leaving the app + You are now leaving the %s app and opening a browser. + Enrollment Error + We are unable to enroll you in this course at this time using the %s mobile application. Please try again on your web browser. + + + + Completed + Past Due + Today + This Week + Next Week + Upcoming + None + Today + Tomorrow + Yesterday + %1$s days ago + + %d Item Hidden + %d Items Hidden + + + + Shift due dates + Missed some deadlines? + Don\'t worry - shift our suggested schedule to complete past due assignments without losing any progress. + We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track. + To complete graded assignments as part of this course, you can upgrade today. + You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today. + Your due dates have been successfully shifted to help you stay on track. + Your dates could not be shifted. Please try again. + View all dates + + Register + Sign in + + + %1$s profile image + diff --git a/core/src/main/res/values/themes.xml b/core/src/main/res/values/themes.xml index f0e209956..e6859e022 100644 --- a/core/src/main/res/values/themes.xml +++ b/core/src/main/res/values/themes.xml @@ -12,6 +12,7 @@ ?attr/colorPrimaryVariant + @android:color/transparent + \ No newline at end of file diff --git a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt index 3aff2df2b..06c6ebb3d 100644 --- a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt @@ -20,7 +20,9 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.config.Config import java.net.UnknownHostException +import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class CourseContainerViewModelTest { @@ -31,6 +33,7 @@ class CourseContainerViewModelTest { private val dispatcher = StandardTestDispatcher() private val resourceManager = mockk() + private val config = mockk() private val interactor = mockk() private val networkConnection = mockk() private val notifier = spyk() @@ -46,7 +49,7 @@ class CourseContainerViewModelTest { name = "Course name", number = "", org = "Org", - start = null, + start = Date(0), startDisplay = "", startType = "", end = null, @@ -79,6 +82,8 @@ class CourseContainerViewModelTest { fun `preloadCourseStructure internet connection exception`() = runTest { val viewModel = CourseContainerViewModel( "", + "", + config, interactor, resourceManager, notifier, @@ -102,6 +107,8 @@ class CourseContainerViewModelTest { fun `preloadCourseStructure unknown exception`() = runTest { val viewModel = CourseContainerViewModel( "", + "", + config, interactor, resourceManager, notifier, @@ -125,6 +132,8 @@ class CourseContainerViewModelTest { fun `preloadCourseStructure success with internet`() = runTest { val viewModel = CourseContainerViewModel( "", + "", + config, interactor, resourceManager, notifier, @@ -148,6 +157,8 @@ class CourseContainerViewModelTest { fun `preloadCourseStructure success without internet`() = runTest { val viewModel = CourseContainerViewModel( "", + "", + config, interactor, resourceManager, notifier, @@ -172,6 +183,8 @@ class CourseContainerViewModelTest { fun `updateData no internet connection exception`() = runTest { val viewModel = CourseContainerViewModel( "", + "", + config, interactor, resourceManager, notifier, @@ -194,6 +207,8 @@ class CourseContainerViewModelTest { fun `updateData unknown exception`() = runTest { val viewModel = CourseContainerViewModel( "", + "", + config, interactor, resourceManager, notifier, @@ -216,6 +231,8 @@ class CourseContainerViewModelTest { fun `updateData success`() = runTest { val viewModel = CourseContainerViewModel( "", + "", + config, interactor, resourceManager, notifier, diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt new file mode 100644 index 000000000..6419ed2cd --- /dev/null +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -0,0 +1,182 @@ +package org.openedx.course.presentation.dates + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.data.model.DateType +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DatesSection +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.course.domain.interactor.CourseInteractor +import java.net.UnknownHostException +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class CourseDatesViewModelTest { + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + private val resourceManager = mockk() + private val interactor = mockk() + private val networkConnection = mockk() + + private val noInternet = "Slow or no internet connection" + private val somethingWrong = "Something went wrong" + + private val dateBlock = CourseDateBlock( + complete = false, + date = Date(), + dateType = DateType.TODAY_DATE, + description = "Mocked Course Date Description" + ) + private val mockDateBlocks = linkedMapOf( + Pair( + DatesSection.COMPLETED, + listOf(dateBlock, dateBlock) + ), + Pair( + DatesSection.PAST_DUE, + listOf(dateBlock, dateBlock) + ), + Pair( + DatesSection.TODAY, + listOf(dateBlock, dateBlock) + ) + ) + private val mockCourseDatesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = true, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = true, + ) + private val mockedCourseDatesResult = CourseDatesResult( + datesSection = mockDateBlocks, + courseBanner = mockCourseDatesBannerInfo, + ) + private val courseStructure = CourseStructure( + root = "", + blockData = listOf(), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(0), + startDisplay = "", + startType = "", + end = null, + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = true, + ) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { interactor.getCourseStructureFromCache() } returns courseStructure + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getCourseDates no internet connection exception`() = runTest { + val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + + Assert.assertEquals(noInternet, message?.message) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Loading) + } + + @Test + fun `getCourseDates unknown exception`() = runTest { + val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } throws Exception() + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + + Assert.assertEquals(somethingWrong, message?.message) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Loading) + } + + @Test + fun `getCourseDates success with internet`() = runTest { + val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult + + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + assert(viewModel.uiMessage.value == null) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Dates) + } + + @Test + fun `getCourseDates success with EmptyList`() = runTest { + val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( + datesSection = linkedMapOf(), + courseBanner = mockCourseDatesBannerInfo, + ) + + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + assert(viewModel.uiMessage.value == null) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Empty) + } +} diff --git a/course/src/test/java/org/openedx/course/presentation/detail/CourseDetailsViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/detail/CourseDetailsViewModelTest.kt index e820a1ec7..5f67f172c 100644 --- a/course/src/test/java/org/openedx/course/presentation/detail/CourseDetailsViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/detail/CourseDetailsViewModelTest.kt @@ -1,27 +1,38 @@ package org.openedx.course.presentation.detail import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.Course -import org.openedx.core.domain.model.Media -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.CourseDashboardUpdate -import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.course.presentation.CourseAnalytics -import io.mockk.* +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Course +import org.openedx.core.domain.model.Media +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -32,6 +43,8 @@ class CourseDetailsViewModelTest { private val dispatcher = StandardTestDispatcher() + private val config = mockk() + private val preferencesManager = mockk() private val resourceManager = mockk() private val interactor = mockk() private val networkConnection = mockk() @@ -70,6 +83,7 @@ class CourseDetailsViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { config.getApiHostURL() } returns "http://localhost:8000" } @After @@ -79,8 +93,16 @@ class CourseDetailsViewModelTest { @Test fun `getCourseDetails no internet connection exception`() = runTest { - val viewModel = - CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = CourseDetailsViewModel( + "", + config, + preferencesManager, + networkConnection, + interactor, + resourceManager, + notifier, + analytics + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } throws UnknownHostException() advanceUntilIdle() @@ -95,8 +117,16 @@ class CourseDetailsViewModelTest { @Test fun `getCourseDetails unknown exception`() = runTest { - val viewModel = - CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = CourseDetailsViewModel( + "", + config, + preferencesManager, + networkConnection, + interactor, + resourceManager, + notifier, + analytics + ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } throws Exception() advanceUntilIdle() @@ -111,8 +141,18 @@ class CourseDetailsViewModelTest { @Test fun `getCourseDetails success with internet`() = runTest { - val viewModel = - CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = CourseDetailsViewModel( + "", + config, + preferencesManager, + networkConnection, + interactor, + resourceManager, + notifier, + analytics + ) + every { config.isPreLoginExperienceEnabled() } returns false + every { preferencesManager.user } returns null every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDetails(any()) } returns mockk() @@ -126,8 +166,18 @@ class CourseDetailsViewModelTest { @Test fun `getCourseDetails success without internet`() = runTest { - val viewModel = - CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = CourseDetailsViewModel( + "", + config, + preferencesManager, + networkConnection, + interactor, + resourceManager, + notifier, + analytics + ) + every { config.isPreLoginExperienceEnabled() } returns false + every { preferencesManager.user } returns null every { networkConnection.isOnline() } returns false coEvery { interactor.getCourseDetailsFromCache(any()) } returns mockk() @@ -142,8 +192,18 @@ class CourseDetailsViewModelTest { @Test fun `enrollInACourse internet connection error`() = runTest { - val viewModel = - CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = CourseDetailsViewModel( + "", + config, + preferencesManager, + networkConnection, + interactor, + resourceManager, + notifier, + analytics + ) + every { config.isPreLoginExperienceEnabled() } returns false + every { preferencesManager.user } returns null coEvery { interactor.enrollInACourse(any()) } throws UnknownHostException() coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true @@ -165,8 +225,18 @@ class CourseDetailsViewModelTest { @Test fun `enrollInACourse unknown exception`() = runTest { - val viewModel = - CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = CourseDetailsViewModel( + "", + config, + preferencesManager, + networkConnection, + interactor, + resourceManager, + notifier, + analytics + ) + every { config.isPreLoginExperienceEnabled() } returns false + every { preferencesManager.user } returns null coEvery { interactor.enrollInACourse(any()) } throws Exception() coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true @@ -189,8 +259,18 @@ class CourseDetailsViewModelTest { @Test fun `enrollInACourse success`() = runTest { - val viewModel = - CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = CourseDetailsViewModel( + "", + config, + preferencesManager, + networkConnection, + interactor, + resourceManager, + notifier, + analytics + ) + every { config.isPreLoginExperienceEnabled() } returns false + every { preferencesManager.user } returns null every { analytics.courseEnrollClickedEvent(any(), any()) } returns Unit every { analytics.courseEnrollSuccessEvent(any(), any()) } returns Unit coEvery { interactor.enrollInACourse(any()) } returns Unit @@ -213,8 +293,16 @@ class CourseDetailsViewModelTest { @Test fun `getCourseAboutBody contains black`() { - val viewModel = - CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = CourseDetailsViewModel( + "", + config, + preferencesManager, + networkConnection, + interactor, + resourceManager, + notifier, + analytics + ) val overview = viewModel.getCourseAboutBody(ULong.MAX_VALUE, ULong.MIN_VALUE) val count = overview.contains("black") assert(count) @@ -222,11 +310,19 @@ class CourseDetailsViewModelTest { @Test fun `getCourseAboutBody don't contains black`() { - val viewModel = - CourseDetailsViewModel("", networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = CourseDetailsViewModel( + "", + config, + preferencesManager, + networkConnection, + interactor, + resourceManager, + notifier, + analytics + ) val overview = viewModel.getCourseAboutBody(ULong.MAX_VALUE, ULong.MAX_VALUE) val count = overview.contains("black") assert(!count) } -} \ No newline at end of file +} diff --git a/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt index cbd2821a9..7c8ab38df 100644 --- a/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt @@ -1,10 +1,9 @@ package org.openedx.course.presentation.handouts import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.domain.model.* -import org.openedx.course.domain.interactor.CourseInteractor import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -15,6 +14,9 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.config.Config +import org.openedx.core.domain.model.* +import org.openedx.course.domain.interactor.CourseInteractor import java.net.UnknownHostException import java.util.* @@ -26,6 +28,7 @@ class HandoutsViewModelTest { private val dispatcher = StandardTestDispatcher() + private val config = mockk() private val interactor = mockk() //region mockHandoutsModel @@ -37,6 +40,7 @@ class HandoutsViewModelTest { @Before fun setUp() { Dispatchers.setMain(dispatcher) + every { config.getApiHostURL() } returns "http://localhost:8000" } @After @@ -46,7 +50,7 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse no internet connection exception`() = runTest { - val viewModel = HandoutsViewModel("", "Handouts", interactor) + val viewModel = HandoutsViewModel("", config, "Handouts", interactor) coEvery { interactor.getHandouts(any()) } throws UnknownHostException() advanceUntilIdle() @@ -56,7 +60,7 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse unknown exception`() = runTest { - val viewModel = HandoutsViewModel("", "Handouts", interactor) + val viewModel = HandoutsViewModel("", config, "Handouts", interactor) coEvery { interactor.getHandouts(any()) } throws Exception() advanceUntilIdle() @@ -65,7 +69,7 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse handouts success`() = runTest { - val viewModel = HandoutsViewModel("", HandoutsType.Handouts.name, interactor) + val viewModel = HandoutsViewModel("", config, HandoutsType.Handouts.name, interactor) coEvery { interactor.getHandouts(any()) } returns HandoutsModel("hello") advanceUntilIdle() @@ -77,7 +81,7 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse announcements success`() = runTest { - val viewModel = HandoutsViewModel("", HandoutsType.Announcements.name, interactor) + val viewModel = HandoutsViewModel("", config, HandoutsType.Announcements.name, interactor) coEvery { interactor.getAnnouncements(any()) } returns listOf( AnnouncementModel( "date", @@ -94,7 +98,7 @@ class HandoutsViewModelTest { @Test fun `injectDarkMode test`() = runTest { - val viewModel = HandoutsViewModel("", HandoutsType.Announcements.name, interactor) + val viewModel = HandoutsViewModel("", config, HandoutsType.Announcements.name, interactor) coEvery { interactor.getAnnouncements(any()) } returns listOf( AnnouncementModel( "date", diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index 259637f67..e26cb019f 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -4,18 +4,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import org.openedx.core.BlockType -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.* -import org.openedx.core.module.DownloadWorkerController -import org.openedx.core.module.db.* -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.core.system.notifier.CourseStructureUpdated -import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.course.presentation.CourseAnalytics import io.mockk.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -28,7 +16,20 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.* +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.* +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics import java.net.UnknownHostException import java.util.* @@ -40,6 +41,7 @@ class CourseOutlineViewModelTest { private val dispatcher = UnconfinedTestDispatcher() + private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() private val preferencesManager = mockk() @@ -67,6 +69,7 @@ class CourseOutlineViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), + descendantsType = BlockType.HTML, completion = 0.0 ), Block( @@ -82,6 +85,7 @@ class CourseOutlineViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = listOf("id2"), + descendantsType = BlockType.HTML, completion = 0.0 ), Block( @@ -97,6 +101,7 @@ class CourseOutlineViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = emptyList(), + descendantsType = BlockType.HTML, completion = 0.0 ) ) @@ -125,6 +130,14 @@ class CourseOutlineViewModelTest { isSelfPaced = false ) + private val mockCourseDatesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = true, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = true, + ) + private val downloadModel = DownloadModel( "id", "title", @@ -142,6 +155,7 @@ class CourseOutlineViewModelTest { every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload + every { config.getApiHostURL() } returns "http://localhost:8000" } @After @@ -157,6 +171,7 @@ class CourseOutlineViewModelTest { val viewModel = CourseOutlineViewModel( "", + config, interactor, resourceManager, notifier, @@ -185,6 +200,7 @@ class CourseOutlineViewModelTest { coEvery { interactor.getCourseStatus(any()) } throws Exception() val viewModel = CourseOutlineViewModel( "", + config, interactor, resourceManager, notifier, @@ -220,9 +236,12 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") + every { config.isCourseNestedListEnabled() } returns false + coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", + config, interactor, resourceManager, notifier, @@ -257,9 +276,11 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") + every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( "", + config, interactor, resourceManager, notifier, @@ -294,9 +315,12 @@ class CourseOutlineViewModelTest { ) } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") + every { config.isCourseNestedListEnabled() } returns false + coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", + config, interactor, resourceManager, notifier, @@ -322,6 +346,7 @@ class CourseOutlineViewModelTest { fun `CourseStructureUpdated notifier test`() = runTest { val viewModel = CourseOutlineViewModel( "", + config, interactor, resourceManager, notifier, @@ -363,9 +388,12 @@ class CourseOutlineViewModelTest { coEvery { workerController.saveModels(*anyVararg()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { config.isCourseNestedListEnabled() } returns false + coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", + config, interactor, resourceManager, notifier, @@ -392,9 +420,12 @@ class CourseOutlineViewModelTest { coEvery { downloadDao.readAllData() } returns mockk() coEvery { workerController.saveModels(*anyVararg()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { config.isCourseNestedListEnabled() } returns false + coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", + config, interactor, resourceManager, notifier, @@ -419,9 +450,11 @@ class CourseOutlineViewModelTest { every { networkConnection.isOnline() } returns false coEvery { workerController.saveModels(*anyVararg()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( "", + config, interactor, resourceManager, notifier, diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index 3dba571a6..db3309093 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -74,6 +74,7 @@ class CourseSectionViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), + descendantsType = BlockType.HTML, completion = 0.0 ), Block( @@ -89,6 +90,7 @@ class CourseSectionViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = listOf("id2"), + descendantsType = BlockType.HTML, completion = 0.0 ), Block( @@ -104,6 +106,7 @@ class CourseSectionViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = emptyList(), + descendantsType = BlockType.HTML, completion = 0.0 ) ) diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index 7313be328..c087a3158 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -1,28 +1,31 @@ package org.openedx.course.presentation.unit.container import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.BlockType -import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.course.presentation.CourseAnalytics import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.* import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.BlockType +import org.openedx.core.config.Config +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.system.notifier.CourseEvent +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics import java.net.UnknownHostException -import java.util.* +import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class CourseUnitContainerViewModelTest { @@ -32,6 +35,7 @@ class CourseUnitContainerViewModelTest { private val dispatcher = StandardTestDispatcher() + private val config = mockk() private val interactor = mockk() private val notifier = mockk() private val analytics = mockk() @@ -50,6 +54,7 @@ class CourseUnitContainerViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = listOf("id2", "id1"), + descendantsType = BlockType.HTML, completion = 0.0 ), Block( @@ -65,6 +70,7 @@ class CourseUnitContainerViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = listOf("id2", "id"), + descendantsType = BlockType.HTML, completion = 0.0 ), Block( @@ -80,6 +86,7 @@ class CourseUnitContainerViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = emptyList(), + descendantsType = BlockType.HTML, completion = 0.0 ), Block( @@ -95,6 +102,7 @@ class CourseUnitContainerViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = emptyList(), + descendantsType = BlockType.HTML, completion = 0.0 ) @@ -136,7 +144,8 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks no internet connection exception`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, notifier, analytics, "") + every { notifier.notifier } returns MutableSharedFlow() + val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") every { interactor.getCourseStructureFromCache() } throws UnknownHostException() every { interactor.getCourseStructureForVideos() } throws UnknownHostException() @@ -149,7 +158,8 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks unknown exception`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, notifier, analytics, "") + every { notifier.notifier } returns MutableSharedFlow() + val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") every { interactor.getCourseStructureFromCache() } throws UnknownHostException() every { interactor.getCourseStructureForVideos() } throws UnknownHostException() @@ -162,7 +172,8 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks unknown success`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, notifier, analytics, "") + every { notifier.notifier } returns MutableSharedFlow() + val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure @@ -176,8 +187,9 @@ class CourseUnitContainerViewModelTest { } @Test - fun `setupCurrentIndex`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, notifier, analytics, "") + fun setupCurrentIndex() = runTest { + every { notifier.notifier } returns MutableSharedFlow() + val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure @@ -191,7 +203,8 @@ class CourseUnitContainerViewModelTest { @Test fun `getCurrentBlock test`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, notifier, analytics, "") + every { notifier.notifier } returns MutableSharedFlow() + val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure @@ -207,7 +220,8 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToPrevBlock null`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, notifier, analytics, "") + every { notifier.notifier } returns MutableSharedFlow() + val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure @@ -223,23 +237,25 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToPrevBlock not null`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, notifier, analytics, "") + every { notifier.notifier } returns MutableSharedFlow() + val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure viewModel.loadBlocks(CourseViewMode.VIDEOS) - viewModel.setupCurrentIndex("id3") + viewModel.setupCurrentIndex("id1", "id") advanceUntilIdle() verify(exactly = 0) { interactor.getCourseStructureFromCache() } verify(exactly = 1) { interactor.getCourseStructureForVideos() } - assert(viewModel.moveToPrevBlock() == null) + assert(viewModel.moveToPrevBlock() != null) } @Test fun `moveToNextBlock null`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, notifier, analytics, "") + every { notifier.notifier } returns MutableSharedFlow() + val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure @@ -255,7 +271,8 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToNextBlock not null`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, notifier, analytics, "") + every { notifier.notifier } returns MutableSharedFlow() + val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure @@ -271,7 +288,8 @@ class CourseUnitContainerViewModelTest { @Test fun `currentIndex isLastIndex`() = runTest { - val viewModel = CourseUnitContainerViewModel(interactor, notifier, analytics, "") + every { notifier.notifier } returns MutableSharedFlow() + val viewModel = CourseUnitContainerViewModel(config, interactor, notifier, analytics, "") every { interactor.getCourseStructureFromCache() } returns courseStructure every { interactor.getCourseStructureForVideos() } returns courseStructure diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt index 30a06d0c7..a77b6ae38 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt @@ -107,7 +107,7 @@ class VideoUnitViewModelTest { networkConnection, transcriptManager ) - coEvery { notifier.notifier } returns flow { emit(CourseVideoPositionChanged("", 10)) } + coEvery { notifier.notifier } returns flow { emit(CourseVideoPositionChanged("", 10, false)) } val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) lifecycleRegistry.addObserver(viewModel) diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt index e517d192a..ce1799432 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt @@ -15,6 +15,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.data.storage.CorePreferences @OptIn(ExperimentalCoroutinesApi::class) class VideoViewModelTest { @@ -26,6 +27,7 @@ class VideoViewModelTest { private val courseRepository = mockk() private val notifier = mockk() + private val preferenceManager = mockk() @Before fun setUp() { @@ -39,17 +41,17 @@ class VideoViewModelTest { @Test fun `sendTime test`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier) - coEvery { notifier.send(CourseVideoPositionChanged("", 0)) } returns Unit + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) + coEvery { notifier.send(CourseVideoPositionChanged("", 0, false)) } returns Unit viewModel.sendTime() advanceUntilIdle() - coVerify(exactly = 1) { notifier.send(CourseVideoPositionChanged("", 0)) } + coVerify(exactly = 1) { notifier.send(CourseVideoPositionChanged("", 0, false)) } } @Test fun `markBlockCompleted exception`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) coEvery { courseRepository.markBlocksCompletion( any(), @@ -69,7 +71,7 @@ class VideoViewModelTest { @Test fun `markBlockCompleted success`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) coEvery { courseRepository.markBlocksCompletion( any(), diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index c0f8b652c..9057df980 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -4,7 +4,20 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import io.mockk.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule import org.openedx.core.BlockType +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure @@ -18,18 +31,7 @@ import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor -import io.mockk.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.* -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TestRule -import org.openedx.core.data.storage.CorePreferences +import org.openedx.course.presentation.CourseAnalytics import java.util.* @OptIn(ExperimentalCoroutinesApi::class) @@ -39,9 +41,11 @@ class CourseVideoViewModelTest { private val dispatcher = StandardTestDispatcher() + private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() private val notifier = spyk() + private val analytics = mockk() private val preferencesManager = mockk() private val networkConnection = mockk() private val downloadDao = mockk() @@ -63,6 +67,7 @@ class CourseVideoViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = listOf("1", "id1"), + descendantsType = BlockType.HTML, completion = 0.0 ), Block( @@ -78,6 +83,7 @@ class CourseVideoViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = listOf("id2"), + descendantsType = BlockType.HTML, completion = 0.0 ), Block( @@ -93,6 +99,7 @@ class CourseVideoViewModelTest { studentViewMultiDevice = false, blockCounts = BlockCounts(0), descendants = emptyList(), + descendantsType = BlockType.HTML, completion = 0.0 ) ) @@ -130,6 +137,7 @@ class CourseVideoViewModelTest { every { resourceManager.getString(R.string.course_does_not_include_videos) } returns "" every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload Dispatchers.setMain(dispatcher) + every { config.getApiHostURL() } returns "http://localhost:8000" } @After @@ -139,16 +147,19 @@ class CourseVideoViewModelTest { @Test fun `getVideos empty list`() = runTest { + every { config.isCourseNestedListEnabled() } returns false every { interactor.getCourseStructureForVideos() } returns courseStructure.copy(blockData = emptyList()) every { downloadDao.readAllData() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", + config, interactor, resourceManager, networkConnection, preferencesManager, notifier, + analytics, downloadDao, workerController ) @@ -163,15 +174,18 @@ class CourseVideoViewModelTest { @Test fun `getVideos success`() = runTest { + every { config.isCourseNestedListEnabled() } returns false every { interactor.getCourseStructureForVideos() } returns courseStructure every { downloadDao.readAllData() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", + config, interactor, resourceManager, networkConnection, preferencesManager, notifier, + analytics, downloadDao, workerController ) @@ -187,6 +201,7 @@ class CourseVideoViewModelTest { @Test fun `updateVideos success`() = runTest { + every { config.isCourseNestedListEnabled() } returns false every { interactor.getCourseStructureForVideos() } returns courseStructure coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } every { downloadDao.readAllData() } returns flow { @@ -197,11 +212,13 @@ class CourseVideoViewModelTest { } val viewModel = CourseVideoViewModel( "", + config, interactor, resourceManager, networkConnection, preferencesManager, notifier, + analytics, downloadDao, workerController ) @@ -221,13 +238,16 @@ class CourseVideoViewModelTest { @Test fun `setIsUpdating success`() = runTest { + every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseVideoViewModel( "", + config, interactor, resourceManager, networkConnection, preferencesManager, notifier, + analytics, downloadDao, workerController ) @@ -241,13 +261,16 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels test`() = runTest { + every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseVideoViewModel( "", + config, interactor, resourceManager, networkConnection, preferencesManager, notifier, + analytics, downloadDao, workerController ) @@ -265,13 +288,16 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest { + every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseVideoViewModel( "", + config, interactor, resourceManager, networkConnection, preferencesManager, notifier, + analytics, downloadDao, workerController ) @@ -289,13 +315,16 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, without conection`() = runTest { + every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseVideoViewModel( "", + config, interactor, resourceManager, networkConnection, preferencesManager, notifier, + analytics, downloadDao, workerController ) diff --git a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt index cf6c3a845..9e83411f5 100644 --- a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt +++ b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt @@ -2,13 +2,25 @@ package org.openedx.dashboard.presentation import androidx.activity.ComponentActivity import androidx.compose.ui.semantics.ProgressBarRangeInfo -import androidx.compose.ui.test.* +import androidx.compose.ui.test.assertAny +import androidx.compose.ui.test.hasAnyChild +import androidx.compose.ui.test.hasProgressBarRangeInfo +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule -import org.openedx.core.domain.model.* -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType +import androidx.compose.ui.test.onChildren import org.junit.Rule import org.junit.Test +import org.openedx.core.AppUpdateState +import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseSharingUtmParameters +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.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.dashboard.presentation.dashboard.DashboardUIState +import org.openedx.dashboard.presentation.dashboard.MyCoursesScreen import java.util.Date class MyCoursesScreenTest { @@ -60,15 +72,17 @@ class MyCoursesScreenTest { composeTestRule.setContent { MyCoursesScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - DashboardUIState.Loading, - null, + apiHostUrl = "http://localhost:8000", + state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), + uiMessage = null, refreshing = false, canLoadMore = false, hasInternetConnection = true, onReloadClick = {}, onSwipeRefresh = {}, paginationCallback = {}, - onItemClick = {} + onItemClick = {}, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), ) } @@ -90,15 +104,17 @@ class MyCoursesScreenTest { composeTestRule.setContent { MyCoursesScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), - null, + apiHostUrl = "http://localhost:8000", + state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), + uiMessage = null, refreshing = false, canLoadMore = false, hasInternetConnection = true, onReloadClick = {}, onSwipeRefresh = {}, paginationCallback = {}, - onItemClick = {} + onItemClick = {}, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), ) } @@ -113,15 +129,17 @@ class MyCoursesScreenTest { composeTestRule.setContent { MyCoursesScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), - null, + apiHostUrl = "http://localhost:8000", + state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), + uiMessage = null, refreshing = true, canLoadMore = false, hasInternetConnection = true, onReloadClick = {}, onSwipeRefresh = {}, paginationCallback = {}, - onItemClick = {} + onItemClick = {}, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), ) } diff --git a/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt b/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt index b43624364..72cb9f380 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt @@ -19,8 +19,8 @@ class DashboardRepository( page = page ) if (page == 1) dao.clearCachedData() - dao.insertEnrolledCourseEntity(*result.results.map { it.mapToRoomEntity() }.toTypedArray()) - return result.mapToDomain() + dao.insertEnrolledCourseEntity(*result.enrollments.results.map { it.mapToRoomEntity() }.toTypedArray()) + return result.enrollments.mapToDomain() } suspend fun getEnrolledCoursesFromCache(): List { diff --git a/dashboard/src/main/java/org/openedx/dashboard/notifier/DashboardEvent.kt b/dashboard/src/main/java/org/openedx/dashboard/notifier/DashboardEvent.kt new file mode 100644 index 000000000..db6532218 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/dashboard/notifier/DashboardEvent.kt @@ -0,0 +1,6 @@ +package org.openedx.dashboard.notifier + +sealed class DashboardEvent { + object NavigationToDiscovery : DashboardEvent() + object UpdateEnrolledCourses : DashboardEvent() +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/notifier/DashboardNotifier.kt b/dashboard/src/main/java/org/openedx/dashboard/notifier/DashboardNotifier.kt new file mode 100644 index 000000000..5e3fa6e22 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/dashboard/notifier/DashboardNotifier.kt @@ -0,0 +1,19 @@ +package org.openedx.dashboard.notifier + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class DashboardNotifier { + + private val channel = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val notifier: Flow = channel.asSharedFlow() + + suspend fun send(event: DashboardEvent) = channel.emit(event) +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt index 42d229330..83ba221e2 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt @@ -10,4 +10,11 @@ interface DashboardRouter { courseTitle: String ) -} \ No newline at end of file + fun navigateToProgramInfo( + fm: FragmentManager, + pathId: String, + + ) + + fun navigateToCourseInfo(fm: FragmentManager, courseId: String, infoType: String) +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardAnalytics.kt similarity index 67% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardAnalytics.kt index 6a69e7a65..a0ce2285e 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardAnalytics.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardAnalytics.kt @@ -1,4 +1,4 @@ -package org.openedx.dashboard.presentation +package org.openedx.dashboard.presentation.dashboard interface DashboardAnalytics { fun dashboardCourseClickedEvent(courseId: String, courseName: String) diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt similarity index 85% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt index 45a81e378..849423409 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.dashboard.presentation +package org.openedx.dashboard.presentation.dashboard import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES @@ -39,18 +39,30 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import coil.compose.AsyncImage import coil.request.ImageRequest -import org.openedx.core.BuildConfig +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.AppUpdateState import org.openedx.core.UIMessage import org.openedx.core.domain.model.* -import org.openedx.core.ui.* +import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox +import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils import org.openedx.dashboard.R -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.dashboard.presentation.DashboardRouter import java.util.* class DashboardFragment : Fragment() { @@ -76,9 +88,11 @@ class DashboardFragment : Fragment() { val uiMessage by viewModel.uiMessage.observeAsState() val refreshing by viewModel.updating.observeAsState(false) val canLoadMore by viewModel.canLoadMore.observeAsState(false) + val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() MyCoursesScreen( windowSize = windowSize, + viewModel.apiHostUrl, uiState!!, uiMessage, canLoadMore = canLoadMore, @@ -100,7 +114,13 @@ class DashboardFragment : Fragment() { }, paginationCallback = { viewModel.fetchMore() - } + }, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters( + appUpgradeEvent = appUpgradeEvent, + onAppUpgradeRecommendedBoxClick = { + AppUpdateState.openPlayMarket(requireContext()) + }, + ), ) } } @@ -111,6 +131,7 @@ class DashboardFragment : Fragment() { @Composable internal fun MyCoursesScreen( windowSize: WindowSize, + apiHostUrl: String, state: DashboardUIState, uiMessage: UIMessage?, canLoadMore: Boolean, @@ -120,6 +141,7 @@ internal fun MyCoursesScreen( onSwipeRefresh: () -> Unit, paginationCallback: () -> Unit, onItemClick: (EnrolledCourse) -> Unit, + appUpgradeParameters: AppUpdateState.AppUpgradeParameters, ) { val scaffoldState = rememberScaffoldState() val pullRefreshState = @@ -177,18 +199,11 @@ internal fun MyCoursesScreen( Column( modifier = Modifier .padding(paddingValues) - .statusBarsInset(), + .statusBarsInset() + .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp, bottom = 12.dp), - text = stringResource(id = R.string.dashboard_title), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - textAlign = TextAlign.Center - ) + Toolbar(label = stringResource(id = R.string.dashboard_title)) Surface( color = MaterialTheme.appColors.background, @@ -238,9 +253,11 @@ internal fun MyCoursesScreen( } } items(state.courses) { course -> - CourseItem(course, windowSize, onClick = { - onItemClick(it) - }) + CourseItem( + apiHostUrl, + course, + windowSize, + onClick = { onItemClick(it) }) Divider() } item { @@ -294,19 +311,35 @@ internal fun MyCoursesScreen( pullRefreshState, Modifier.align(Alignment.TopCenter) ) - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) { + when (appUpgradeParameters.appUpgradeEvent) { + is AppUpgradeEvent.UpgradeRecommendedEvent -> { + AppUpgradeRecommendedBox( + modifier = Modifier.fillMaxWidth(), + onClick = appUpgradeParameters.onAppUpgradeRecommendedBoxClick + ) } - ) + + else -> {} + } + + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth(), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onReloadClick() + } + ) + } } } } @@ -316,6 +349,7 @@ internal fun MyCoursesScreen( @Composable private fun CourseItem( + apiHostUrl: String, enrolledCourse: EnrolledCourse, windowSize: WindowSize, onClick: (EnrolledCourse) -> Unit @@ -328,7 +362,7 @@ private fun CourseItem( ) ) } - val imageUrl = org.openedx.core.BuildConfig.BASE_URL.dropLast(1) + enrolledCourse.course.courseImage + val imageUrl = apiHostUrl.dropLast(1) + enrolledCourse.course.courseImage val context = LocalContext.current Surface( modifier = Modifier @@ -464,6 +498,7 @@ private fun EmptyState() { private fun CourseItemPreview() { OpenEdXTheme() { CourseItem( + "http://localhost:8000", mockCourseEnrolled, WindowSize(WindowType.Compact, WindowType.Compact), onClick = {}) @@ -477,6 +512,7 @@ private fun MyCoursesScreenDay() { OpenEdXTheme { MyCoursesScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses( listOf( mockCourseEnrolled, @@ -494,7 +530,8 @@ private fun MyCoursesScreenDay() { hasInternetConnection = true, refreshing = false, canLoadMore = false, - paginationCallback = {} + paginationCallback = {}, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } } @@ -506,6 +543,7 @@ private fun MyCoursesScreenTabletPreview() { OpenEdXTheme { MyCoursesScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses( listOf( mockCourseEnrolled, @@ -523,7 +561,8 @@ private fun MyCoursesScreenTabletPreview() { hasInternetConnection = true, refreshing = false, canLoadMore = false, - paginationCallback = {} + paginationCallback = {}, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } } @@ -563,4 +602,4 @@ private val mockCourseEnrolled = EnrolledCourse( videoOutline = "", isSelfPaced = false ) -) \ No newline at end of file +) diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardUIState.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardUIState.kt similarity index 82% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardUIState.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardUIState.kt index 5ea81cfd0..b0c5b1daa 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardUIState.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardUIState.kt @@ -1,4 +1,4 @@ -package org.openedx.dashboard.presentation +package org.openedx.dashboard.presentation.dashboard import org.openedx.core.domain.model.EnrolledCourse diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardViewModel.kt similarity index 79% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardViewModel.kt index 714d0c91e..9316d831f 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/dashboard/DashboardViewModel.kt @@ -1,34 +1,48 @@ -package org.openedx.dashboard.presentation +package org.openedx.dashboard.presentation.dashboard import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage +import org.openedx.core.config.Config import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.CourseNotifier import org.openedx.dashboard.domain.interactor.DashboardInteractor -import kotlinx.coroutines.launch +import org.openedx.dashboard.notifier.DashboardEvent +import org.openedx.dashboard.notifier.DashboardNotifier class DashboardViewModel( + private val config: Config, private val networkConnection: NetworkConnection, private val interactor: DashboardInteractor, private val resourceManager: ResourceManager, - private val notifier: CourseNotifier, - private val analytics: DashboardAnalytics + private val courseNotifier: CourseNotifier, + private val dashboardNotifier: DashboardNotifier, + private val analytics: DashboardAnalytics, + private val appUpgradeNotifier: AppUpgradeNotifier ) : BaseViewModel() { private val coursesList = mutableListOf() private var page = 1 private var isLoading = false + + val apiHostUrl get() = config.getApiHostURL() + private val _uiState = MutableLiveData(DashboardUIState.Loading) val uiState: LiveData get() = _uiState @@ -48,19 +62,29 @@ class DashboardViewModel( val canLoadMore: LiveData get() = _canLoadMore + private val _appUpgradeEvent = MutableLiveData() + val appUpgradeEvent: LiveData + get() = _appUpgradeEvent + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) viewModelScope.launch { - notifier.notifier.collect { + courseNotifier.notifier.collect { if (it is CourseDashboardUpdate) { updateCourses() } } } + dashboardNotifier.notifier.onEach { + if (it is DashboardEvent.UpdateEnrolledCourses) { + updateCourses() + } + }.distinctUntilChanged().launchIn(viewModelScope) } init { getCourses() + collectAppUpgradeEvent() } fun getCourses() { @@ -113,7 +137,7 @@ class DashboardViewModel( } else { null } - if (response !=null) { + if (response != null) { if (response.pagination.next.isNotEmpty() && page != response.pagination.numPages) { _canLoadMore.value = true page++ @@ -153,6 +177,14 @@ class DashboardViewModel( } } + private fun collectAppUpgradeEvent() { + viewModelScope.launch { + appUpgradeNotifier.notifier.collect { event -> + _appUpgradeEvent.value = event + } + } + } + fun dashboardCourseClickedEvent(courseId: String, courseName: String) { analytics.dashboardCourseClickedEvent(courseId, courseName) } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramFragment.kt new file mode 100644 index 000000000..00da95a6e --- /dev/null +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramFragment.kt @@ -0,0 +1,339 @@ +package org.openedx.dashboard.presentation.program + +import android.content.res.Configuration +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.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.zIndex +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.extension.toastMessage +import org.openedx.core.presentation.catalog.CatalogWebViewScreen +import org.openedx.core.presentation.catalog.WebViewLink +import org.openedx.core.presentation.dialog.alert.ActionDialogFragment +import org.openedx.core.presentation.dialog.alert.InfoDialogFragment +import org.openedx.core.ui.ConnectionErrorView +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.windowSizeValue +import org.openedx.dashboard.R +import org.openedx.core.R as coreR +import org.openedx.core.presentation.catalog.WebViewLink.Authority as linkAuthority + +class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { + + private val viewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (myPrograms.not()) { + lifecycle.addObserver(viewModel) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val uiState by viewModel.uiState.collectAsState(initial = ProgramUIState.Loading) + var hasInternetConnection by remember { + mutableStateOf(viewModel.hasInternetConnection) + } + + if (myPrograms.not()) { + DisposableEffect(uiState is ProgramUIState.CourseEnrolled) { + if (uiState is ProgramUIState.CourseEnrolled) { + + val courseId = (uiState as ProgramUIState.CourseEnrolled).courseId + val isEnrolled = (uiState as ProgramUIState.CourseEnrolled).isEnrolled + + if (isEnrolled) { + viewModel.onEnrolledCourseClick( + fragmentManager = requireActivity().supportFragmentManager, + courseId = courseId, + ) + context.toastMessage(getString(R.string.dashboard_enrolled_successfully)) + } else { + InfoDialogFragment.newInstance( + title = getString(coreR.string.core_enrollment_error), + message = getString(coreR.string.core_enrollment_error_message) + ).show( + requireActivity().supportFragmentManager, + InfoDialogFragment::class.simpleName + ) + } + } + onDispose {} + } + } + + ProgramInfoScreen( + windowSize = windowSize, + uiState = uiState, + contentUrl = getInitialUrl(), + canShowBackBtn = arguments?.getString(ARG_PATH_ID, "") + ?.isNotEmpty() == true, + uriScheme = viewModel.uriScheme, + hasInternetConnection = hasInternetConnection, + checkInternetConnection = { + hasInternetConnection = viewModel.hasInternetConnection + }, + onWebPageLoaded = { viewModel.showLoading(false) }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStackImmediate() + }, + onUriClick = { param, type -> + when (type) { + linkAuthority.ENROLLED_COURSE_INFO -> { + viewModel.onEnrolledCourseClick( + fragmentManager = requireActivity().supportFragmentManager, + courseId = param + ) + } + + linkAuthority.ENROLLED_PROGRAM_INFO -> { + viewModel.onProgramCardClick( + fragmentManager = requireActivity().supportFragmentManager, + pathId = param + ) + } + + linkAuthority.PROGRAM_INFO, + linkAuthority.COURSE_INFO -> { + viewModel.onViewCourseClick( + fragmentManager = requireActivity().supportFragmentManager, + courseId = param, + infoType = type.name + ) + } + + linkAuthority.ENROLL -> { + viewModel.enrollInACourse(param) + } + + linkAuthority.COURSE -> { + viewModel.navigateToDiscovery() + } + + linkAuthority.EXTERNAL -> { + ActionDialogFragment.newInstance( + title = getString(coreR.string.core_leaving_the_app), + message = getString( + coreR.string.core_leaving_the_app_message, + getString(coreR.string.platform_name) + ), + url = param, + ).show( + requireActivity().supportFragmentManager, + ActionDialogFragment::class.simpleName + ) + } + + else -> {} + } + }, + refreshSessionCookie = { + viewModel.refreshCookie() + }, + ) + } + } + } + + + private fun getInitialUrl(): String { + return arguments?.let { args -> + val pathId = args.getString(ARG_PATH_ID) ?: "" + viewModel.programConfig.programDetailUrlTemplate.replace("{$ARG_PATH_ID}", pathId) + } ?: viewModel.programConfig.programUrl + } + + companion object { + private const val ARG_PATH_ID = "path_id" + + fun newInstance( + pathId: String, + ): ProgramFragment { + val fragment = ProgramFragment(false) + fragment.arguments = bundleOf( + ARG_PATH_ID to pathId, + ) + return fragment + } + } +} + +@Composable +private fun ProgramInfoScreen( + windowSize: WindowSize, + uiState: ProgramUIState?, + contentUrl: String, + uriScheme: String, + canShowBackBtn: Boolean, + hasInternetConnection: Boolean, + checkInternetConnection: () -> Unit, + onWebPageLoaded: () -> Unit, + onBackClick: () -> Unit, + onUriClick: (String, WebViewLink.Authority) -> Unit, + refreshSessionCookie: () -> Unit = {}, +) { + val scaffoldState = rememberScaffoldState() + val configuration = LocalConfiguration.current + val isLoading = uiState is ProgramUIState.Loading + + when (uiState) { + is ProgramUIState.UiMessage -> { + HandleUIMessage(uiMessage = uiState.uiMessage, scaffoldState = scaffoldState) + } + + else -> {} + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier.fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background + ) { + val modifierScreenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + Modifier.widthIn(Dp.Unspecified, 560.dp) + } else { + Modifier.widthIn(Dp.Unspecified, 650.dp) + }, + compact = Modifier.fillMaxWidth() + ) + ) + } + + Column( + modifier = modifierScreenWidth + .fillMaxSize() + .padding(it) + .statusBarsInset() + .displayCutoutForLandscape(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Toolbar( + label = stringResource(id = R.string.dashboard_programs), + canShowBackBtn = canShowBackBtn, + onBackClick = onBackClick + ) + + Surface { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + contentAlignment = Alignment.TopCenter + ) { + if (hasInternetConnection) { + val webView = CatalogWebViewScreen( + url = contentUrl, + uriScheme = uriScheme, + isAllLinksExternal = true, + onWebPageLoaded = onWebPageLoaded, + refreshSessionCookie = refreshSessionCookie, + onUriClick = onUriClick, + ) + + AndroidView( + modifier = Modifier + .background(MaterialTheme.appColors.background), + factory = { + webView + }, + update = { + webView.loadUrl(contentUrl) + } + ) + } else { + ConnectionErrorView( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(MaterialTheme.appColors.background) + ) { + checkInternetConnection() + } + } + if (isLoading && hasInternetConnection) { + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun MyProgramsPreview() { + OpenEdXTheme { + ProgramInfoScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = ProgramUIState.Loading, + contentUrl = "https://www.example.com/", + uriScheme = "", + canShowBackBtn = false, + hasInternetConnection = false, + checkInternetConnection = {}, + onBackClick = {}, + onWebPageLoaded = {}, + onUriClick = { _, _ -> }, + ) + } +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramUIState.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramUIState.kt new file mode 100644 index 000000000..8f9b83c5c --- /dev/null +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramUIState.kt @@ -0,0 +1,12 @@ +package org.openedx.dashboard.presentation.program + +import org.openedx.core.UIMessage + +sealed class ProgramUIState { + object Loading : ProgramUIState() + object Loaded : ProgramUIState() + + class CourseEnrolled(val courseId: String, val isEnrolled: Boolean) : ProgramUIState() + + class UiMessage(val uiMessage: UIMessage) : ProgramUIState() +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramViewModel.kt new file mode 100644 index 000000000..da5453fdf --- /dev/null +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/program/ProgramViewModel.kt @@ -0,0 +1,105 @@ +package org.openedx.dashboard.presentation.program + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.extension.isInternetError +import org.openedx.core.interfaces.EnrollInCourseInteractor +import org.openedx.core.system.AppCookieManager +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.dashboard.notifier.DashboardEvent +import org.openedx.dashboard.notifier.DashboardNotifier +import org.openedx.dashboard.presentation.DashboardRouter + +class ProgramViewModel( + private val config: Config, + private val networkConnection: NetworkConnection, + private val router: DashboardRouter, + private val notifier: DashboardNotifier, + private val edxCookieManager: AppCookieManager, + private val resourceManager: ResourceManager, + private val courseInteractor: EnrollInCourseInteractor +) : BaseViewModel() { + val uriScheme: String get() = config.getUriScheme() + + val programConfig get() = config.getProgramConfig().webViewConfig + + val hasInternetConnection: Boolean get() = networkConnection.isOnline() + + private val _uiState = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val uiState: SharedFlow get() = _uiState.asSharedFlow() + + fun showLoading(isLoading: Boolean) { + viewModelScope.launch { + _uiState.emit(if (isLoading) ProgramUIState.Loading else ProgramUIState.Loaded) + } + } + + fun enrollInACourse(courseId: String) { + showLoading(true) + viewModelScope.launch { + try { + courseInteractor.enrollInACourse(courseId) + _uiState.emit(ProgramUIState.CourseEnrolled(courseId, true)) + notifier.send(DashboardEvent.UpdateEnrolledCourses) + } catch (e: Exception) { + if (e.isInternetError()) { + _uiState.emit( + ProgramUIState.UiMessage( + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + ) + ) + } else { + _uiState.emit(ProgramUIState.CourseEnrolled(courseId, false)) + } + } + } + } + + fun onProgramCardClick(fragmentManager: FragmentManager, pathId: String) { + if (pathId.isNotEmpty()) { + router.navigateToProgramInfo(fm = fragmentManager, pathId = pathId) + } + } + + fun onViewCourseClick(fragmentManager: FragmentManager, courseId: String, infoType: String) { + if (courseId.isNotEmpty() && infoType.isNotEmpty()) { + router.navigateToCourseInfo( + fm = fragmentManager, + courseId = courseId, + infoType = infoType + ) + } + } + + fun onEnrolledCourseClick(fragmentManager: FragmentManager, courseId: String) { + if (courseId.isNotEmpty()) { + router.navigateToCourseOutline( + fm = fragmentManager, + courseId = courseId, + courseTitle = "" + ) + } + } + + fun navigateToDiscovery() { + viewModelScope.launch { notifier.send(DashboardEvent.NavigationToDiscovery) } + } + + fun refreshCookie() { + viewModelScope.launch { edxCookieManager.tryToRefreshSessionCookie() } + } +} diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index b5f67dbeb..5eb61aadc 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -1,8 +1,10 @@ - + Dashboard Courses + Programs Welcome back. Let\'s keep learning. It\'s empty You are not enrolled in any courses yet. - \ No newline at end of file + You have been successfully enrolled in this course. + diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt index 05624f98b..3ade5f4c6 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt @@ -4,21 +4,15 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.DashboardCourseList -import org.openedx.core.domain.model.Pagination -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.CourseDashboardUpdate -import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.dashboard.domain.interactor.DashboardInteractor import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.* import org.junit.After @@ -27,6 +21,21 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.domain.model.DashboardCourseList +import org.openedx.core.domain.model.Pagination +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.CourseDashboardUpdate +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.dashboard.domain.interactor.DashboardInteractor +import org.openedx.dashboard.notifier.DashboardNotifier +import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics +import org.openedx.dashboard.presentation.dashboard.DashboardUIState +import org.openedx.dashboard.presentation.dashboard.DashboardViewModel import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -37,11 +46,14 @@ class DashboardViewModelTest { private val dispatcher = StandardTestDispatcher() + private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() private val networkConnection = mockk() - private val notifier = mockk() + private val courseNotifier = mockk() + private val dashboardNotifier = spyk() private val analytics = mockk() + private val appUpgradeNotifier = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -56,6 +68,8 @@ class DashboardViewModelTest { Dispatchers.setMain(dispatcher) 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 { config.getApiHostURL() } returns "http://localhost:8000" } @After @@ -65,14 +79,14 @@ class DashboardViewModelTest { @Test fun `getCourses no internet connection`() = runTest { - val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() - advanceUntilIdle() coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -81,14 +95,14 @@ class DashboardViewModelTest { @Test fun `getCourses unknown error`() = runTest { - val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws Exception() - advanceUntilIdle() coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -97,15 +111,15 @@ class DashboardViewModelTest { @Test fun `getCourses from network`() = runTest { - val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList coEvery { interactor.getEnrolledCoursesFromCache() } returns listOf(mockk()) - advanceUntilIdle() coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } + verify(exactly = 1) { appUpgradeNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -113,7 +127,7 @@ class DashboardViewModelTest { @Test fun `getCourses from network with next page`() = runTest { - val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy( Pagination( @@ -129,6 +143,7 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } + verify(exactly = 1) { appUpgradeNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -138,13 +153,13 @@ class DashboardViewModelTest { fun `getCourses from cache`() = runTest { every { networkConnection.isOnline() } returns false coEvery { interactor.getEnrolledCoursesFromCache() } returns listOf(mockk()) - - val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) advanceUntilIdle() coVerify(exactly = 0) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 1) { interactor.getEnrolledCoursesFromCache() } + verify(exactly = 1) { appUpgradeNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -154,7 +169,7 @@ class DashboardViewModelTest { fun `updateCourses no internet error`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() viewModel.updateCourses() @@ -162,6 +177,7 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -173,8 +189,7 @@ class DashboardViewModelTest { fun `updateCourses unknown exception`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - - val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) coEvery { interactor.getEnrolledCourses(any()) } throws Exception() viewModel.updateCourses() @@ -182,6 +197,7 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -193,13 +209,14 @@ class DashboardViewModelTest { fun `updateCourses success`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) viewModel.updateCourses() advanceUntilIdle() coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } + verify(exactly = 1) { appUpgradeNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) @@ -210,13 +227,14 @@ class DashboardViewModelTest { fun `updateCourses success with next page`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy(Pagination(10,"2",2,"")) - val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier, analytics) + val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) viewModel.updateCourses() advanceUntilIdle() coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } + verify(exactly = 1) { appUpgradeNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) @@ -225,9 +243,8 @@ class DashboardViewModelTest { @Test fun `CourseDashboardUpdate notifier test`() = runTest { - coEvery { notifier.notifier } returns flow { emit(CourseDashboardUpdate()) } - - val viewModel = DashboardViewModel(networkConnection, interactor, resourceManager, notifier, analytics) + coEvery { courseNotifier.notifier } returns flow { emit(CourseDashboardUpdate()) } + val viewModel = DashboardViewModel(config, networkConnection, interactor, resourceManager, courseNotifier, dashboardNotifier, analytics, appUpgradeNotifier) val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -237,7 +254,7 @@ class DashboardViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } + verify(exactly = 1) { appUpgradeNotifier.notifier } } - -} \ No newline at end of file +} diff --git a/default_config/config_settings.yaml b/default_config/config_settings.yaml new file mode 100644 index 000000000..249e93fc3 --- /dev/null +++ b/default_config/config_settings.yaml @@ -0,0 +1,5 @@ +config_directory: './default_config' +config_mapping: + prod: 'prod' + stage: 'stage' + dev: 'dev' diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml new file mode 100644 index 000000000..f074ddd9d --- /dev/null +++ b/default_config/dev/config.yaml @@ -0,0 +1,59 @@ +API_HOST_URL: 'http://localhost:8000' +APPLICATION_ID: 'org.openedx.app' +ENVIRONMENT_DISPLAY_NAME: 'Localhost' +URI_SCHEME: '' +FEEDBACK_EMAIL_ADDRESS: 'support@example.com' +FAQ_URL: '' +OAUTH_CLIENT_ID: 'OAUTH_CLIENT_ID' + +# Keep empty to hide setting +AGREEMENT_URLS: + PRIVACY_POLICY_URL: '' + COOKIE_POLICY_URL: '' + DATA_SELL_CONSENT_URL: '' + TOS_URL: '' + EULA_URL: '' + SUPPORTED_LANGUAGES: [ ] #en is defalut language + +DISCOVERY: + TYPE: 'native' + WEBVIEW: + BASE_URL: '' + COURSE_DETAIL_TEMPLATE: '' + PROGRAM_DETAIL_TEMPLATE: '' + +PROGRAM: + TYPE: 'native' + WEBVIEW: + PROGRAM_URL: '' + PROGRAM_DETAIL_URL_TEMPLATE: '' + +GOOGLE: + ENABLED: false + CLIENT_ID: '' + +MICROSOFT: + ENABLED: false + CLIENT_ID: '' + PACKAGE_SIGNATURE: '' + +FACEBOOK: + ENABLED: false + FACEBOOK_APP_ID: '' + CLIENT_TOKEN: '' + +#Platform names +PLATFORM_NAME: "OpenEdX" +PLATFORM_FULL_NAME: "OpenEdX" +#tokenType enum accepts JWT and BEARER only +TOKEN_TYPE: "JWT" +#feature flag for activating What’s New feature +WHATS_NEW_ENABLED: false +#feature flag enable Social Login buttons +SOCIAL_AUTH_ENABLED: false +#Course navigation feature flags +COURSE_NESTED_LIST_ENABLED: false +COURSE_BANNER_ENABLED: true +COURSE_TOP_TAB_BAR_ENABLED: false +COURSE_UNIT_PROGRESS_ENABLED: false + diff --git a/default_config/dev/file_mappings.yaml b/default_config/dev/file_mappings.yaml new file mode 100644 index 000000000..192ee5629 --- /dev/null +++ b/default_config/dev/file_mappings.yaml @@ -0,0 +1,3 @@ +android: + files: + - config.yaml diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml new file mode 100644 index 000000000..25df957a0 --- /dev/null +++ b/default_config/prod/config.yaml @@ -0,0 +1,58 @@ +API_HOST_URL: 'http://localhost:8000' +APPLICATION_ID: 'org.openedx.app' +ENVIRONMENT_DISPLAY_NAME: 'Localhost' +URI_SCHEME: '' +FEEDBACK_EMAIL_ADDRESS: 'support@example.com' +FAQ_URL: '' +OAUTH_CLIENT_ID: 'OAUTH_CLIENT_ID' + +# Keep empty to hide setting +AGREEMENT_URLS: + PRIVACY_POLICY_URL: '' + COOKIE_POLICY_URL: '' + DATA_SELL_CONSENT_URL: '' + TOS_URL: '' + EULA_URL: '' + SUPPORTED_LANGUAGES: [ ] #en is defalut language + +DISCOVERY: + TYPE: 'native' + WEBVIEW: + BASE_URL: '' + COURSE_DETAIL_TEMPLATE: '' + PROGRAM_DETAIL_TEMPLATE: '' + +PROGRAM: + TYPE: 'native' + WEBVIEW: + PROGRAM_URL: '' + PROGRAM_DETAIL_URL_TEMPLATE: '' + +GOOGLE: + ENABLED: false + CLIENT_ID: '' + +MICROSOFT: + ENABLED: false + CLIENT_ID: '' + PACKAGE_SIGNATURE: '' + +FACEBOOK: + ENABLED: false + FACEBOOK_APP_ID: '' + CLIENT_TOKEN: '' + +#Platform names +PLATFORM_NAME: "OpenEdX" +PLATFORM_FULL_NAME: "OpenEdX" +#tokenType enum accepts JWT and BEARER only +TOKEN_TYPE: "JWT" +#feature flag for activating What’s New feature +WHATS_NEW_ENABLED: false +#feature flag enable Social Login buttons +SOCIAL_AUTH_ENABLED: false +#Course navigation feature flags +COURSE_NESTED_LIST_ENABLED: false +COURSE_BANNER_ENABLED: true +COURSE_TOP_TAB_BAR_ENABLED: false +COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/prod/file_mappings.yaml b/default_config/prod/file_mappings.yaml new file mode 100644 index 000000000..192ee5629 --- /dev/null +++ b/default_config/prod/file_mappings.yaml @@ -0,0 +1,3 @@ +android: + files: + - config.yaml diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml new file mode 100644 index 000000000..25df957a0 --- /dev/null +++ b/default_config/stage/config.yaml @@ -0,0 +1,58 @@ +API_HOST_URL: 'http://localhost:8000' +APPLICATION_ID: 'org.openedx.app' +ENVIRONMENT_DISPLAY_NAME: 'Localhost' +URI_SCHEME: '' +FEEDBACK_EMAIL_ADDRESS: 'support@example.com' +FAQ_URL: '' +OAUTH_CLIENT_ID: 'OAUTH_CLIENT_ID' + +# Keep empty to hide setting +AGREEMENT_URLS: + PRIVACY_POLICY_URL: '' + COOKIE_POLICY_URL: '' + DATA_SELL_CONSENT_URL: '' + TOS_URL: '' + EULA_URL: '' + SUPPORTED_LANGUAGES: [ ] #en is defalut language + +DISCOVERY: + TYPE: 'native' + WEBVIEW: + BASE_URL: '' + COURSE_DETAIL_TEMPLATE: '' + PROGRAM_DETAIL_TEMPLATE: '' + +PROGRAM: + TYPE: 'native' + WEBVIEW: + PROGRAM_URL: '' + PROGRAM_DETAIL_URL_TEMPLATE: '' + +GOOGLE: + ENABLED: false + CLIENT_ID: '' + +MICROSOFT: + ENABLED: false + CLIENT_ID: '' + PACKAGE_SIGNATURE: '' + +FACEBOOK: + ENABLED: false + FACEBOOK_APP_ID: '' + CLIENT_TOKEN: '' + +#Platform names +PLATFORM_NAME: "OpenEdX" +PLATFORM_FULL_NAME: "OpenEdX" +#tokenType enum accepts JWT and BEARER only +TOKEN_TYPE: "JWT" +#feature flag for activating What’s New feature +WHATS_NEW_ENABLED: false +#feature flag enable Social Login buttons +SOCIAL_AUTH_ENABLED: false +#Course navigation feature flags +COURSE_NESTED_LIST_ENABLED: false +COURSE_BANNER_ENABLED: true +COURSE_TOP_TAB_BAR_ENABLED: false +COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/stage/file_mappings.yaml b/default_config/stage/file_mappings.yaml new file mode 100644 index 000000000..192ee5629 --- /dev/null +++ b/default_config/stage/file_mappings.yaml @@ -0,0 +1,3 @@ +android: + files: + - config.yaml diff --git a/discovery/build.gradle b/discovery/build.gradle index 8e651abda..881d8c05a 100644 --- a/discovery/build.gradle +++ b/discovery/build.gradle @@ -59,6 +59,7 @@ dependencies { implementation project(path: ':core') kapt "androidx.room:room-compiler:$room_version" + implementation 'androidx.activity:activity-compose:1.8.1' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' @@ -70,4 +71,4 @@ dependencies { testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" -} \ No newline at end of file +} diff --git a/discovery/src/androidTest/java/org/openedx/discovery/presentation/DiscoveryScreenTest.kt b/discovery/src/androidTest/java/org/openedx/discovery/presentation/DiscoveryScreenTest.kt index 3975cb6c7..6a70f334b 100644 --- a/discovery/src/androidTest/java/org/openedx/discovery/presentation/DiscoveryScreenTest.kt +++ b/discovery/src/androidTest/java/org/openedx/discovery/presentation/DiscoveryScreenTest.kt @@ -2,14 +2,20 @@ package org.openedx.discovery.presentation import androidx.activity.ComponentActivity import androidx.compose.ui.semantics.ProgressBarRangeInfo -import androidx.compose.ui.test.* +import androidx.compose.ui.test.assertAny +import androidx.compose.ui.test.hasAnyChild +import androidx.compose.ui.test.hasProgressBarRangeInfo +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onChildren +import org.junit.Rule +import org.junit.Test +import org.openedx.core.AppUpdateState import org.openedx.core.domain.model.Course import org.openedx.core.domain.model.Media import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType -import org.junit.Rule -import org.junit.Test import java.util.Date class DiscoveryScreenTest { @@ -53,11 +59,14 @@ class DiscoveryScreenTest { canLoadMore = false, refreshing = false, hasInternetConnection = true, + canShowBackButton = false, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), onSearchClick = {}, onSwipeRefresh = {}, paginationCallback = {}, onItemClick = {}, - onReloadClick = {} + onReloadClick = {}, + onBackClick = {}, ) } @@ -85,11 +94,14 @@ class DiscoveryScreenTest { canLoadMore = false, refreshing = false, hasInternetConnection = true, + canShowBackButton = false, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), onSearchClick = {}, onSwipeRefresh = {}, paginationCallback = {}, onItemClick = {}, - onReloadClick = {} + onReloadClick = {}, + onBackClick = {}, ) } @@ -108,11 +120,14 @@ class DiscoveryScreenTest { canLoadMore = true, refreshing = false, hasInternetConnection = true, + canShowBackButton = false, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), onSearchClick = {}, onSwipeRefresh = {}, paginationCallback = {}, onItemClick = {}, - onReloadClick = {} + onReloadClick = {}, + onBackClick = {}, ) } diff --git a/discovery/src/main/java/org/openedx/discovery/data/repository/DiscoveryRepository.kt b/discovery/src/main/java/org/openedx/discovery/data/repository/DiscoveryRepository.kt index 2762cf1aa..db2f02d65 100644 --- a/discovery/src/main/java/org/openedx/discovery/data/repository/DiscoveryRepository.kt +++ b/discovery/src/main/java/org/openedx/discovery/data/repository/DiscoveryRepository.kt @@ -20,6 +20,7 @@ class DiscoveryRepository( val pageResponse = api.getCourseList( page = pageNumber, mobile = true, + mobileSearch = false, username = username, org = organization ) @@ -42,7 +43,12 @@ class DiscoveryRepository( query: String, pageNumber: Int, ): CourseList { - val pageResponse = api.getCourseList(searchQuery = query, page = pageNumber, mobile = true) + val pageResponse = api.getCourseList( + searchQuery = query, + page = pageNumber, + mobile = true, + mobileSearch = true + ) return CourseList( pageResponse.pagination.mapToDomain(), pageResponse.results?.map { it.mapToDomain() } ?: emptyList() diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryNavigator.kt b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryNavigator.kt new file mode 100644 index 000000000..ff2e32178 --- /dev/null +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryNavigator.kt @@ -0,0 +1,15 @@ +package org.openedx.discovery.presentation + +import androidx.fragment.app.Fragment + +class DiscoveryNavigator( + private val isDiscoveryTypeWebView: Boolean, +) { + fun getDiscoveryFragment(): Fragment { + return if (isDiscoveryTypeWebView) { + WebViewDiscoveryFragment() + } else { + NativeDiscoveryFragment() + } + } +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt index 12ee9ce93..fa66c542b 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt @@ -6,6 +6,13 @@ interface DiscoveryRouter { fun navigateToCourseDetail(fm: FragmentManager, courseId: String) - fun navigateToCourseSearch(fm: FragmentManager) + fun navigateToCourseSearch(fm: FragmentManager, querySearch: String) -} \ No newline at end of file + fun navigateToUpgradeRequired(fm: FragmentManager) + + fun navigateToCourseInfo(fm: FragmentManager, courseId: String, infoType: String) + + fun navigateToSignUp(fm: FragmentManager, courseId: String? = null) + + fun navigateToSignIn(fm: FragmentManager, courseId: String?) +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt similarity index 58% rename from discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryFragment.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index 2d7809826..f68785720 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -4,17 +4,41 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView @@ -24,21 +48,39 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.AppUpdateState +import org.openedx.core.AppUpdateState.wasUpdateDialogClosed import org.openedx.core.UIMessage import org.openedx.core.domain.model.Course import org.openedx.core.domain.model.Media -import org.openedx.core.ui.* +import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment +import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox +import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.ui.AuthButtonsPanel +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.DiscoveryCourseItem +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.StaticSearchBar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue import org.openedx.discovery.R -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -class DiscoveryFragment : Fragment() { +class NativeDiscoveryFragment : Fragment() { - private val viewModel by viewModel() + private val viewModel by viewModel() private val router: DiscoveryRouter by inject() override fun onCreateView( @@ -55,18 +97,43 @@ class DiscoveryFragment : Fragment() { val uiMessage by viewModel.uiMessage.observeAsState() val canLoadMore by viewModel.canLoadMore.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) + val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() + val wasUpdateDialogClosed by remember { wasUpdateDialogClosed } + val querySearch = arguments?.getString(ARG_SEARCH_QUERY, "") ?: "" DiscoveryScreen( windowSize = windowSize, state = uiState!!, uiMessage = uiMessage, + apiHostUrl = viewModel.apiHostUrl, canLoadMore = canLoadMore, refreshing = refreshing, hasInternetConnection = viewModel.hasInternetConnection, + canShowBackButton = viewModel.canShowBackButton, + isUserLoggedIn = viewModel.isUserLoggedIn, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters( + appUpgradeEvent = appUpgradeEvent, + wasUpdateDialogClosed = wasUpdateDialogClosed, + appUpgradeRecommendedDialog = { + val dialog = AppUpgradeDialogFragment.newInstance() + dialog.show( + requireActivity().supportFragmentManager, + AppUpgradeDialogFragment::class.simpleName + ) + }, + onAppUpgradeRecommendedBoxClick = { + AppUpdateState.openPlayMarket(requireContext()) + }, + onAppUpgradeRequired = { + router.navigateToUpgradeRequired( + requireActivity().supportFragmentManager + ) + } + ), onSearchClick = { viewModel.discoverySearchBarClickedEvent() router.navigateToCourseSearch( - requireActivity().supportFragmentManager + requireActivity().supportFragmentManager, "" ) }, paginationCallback = { @@ -81,13 +148,41 @@ class DiscoveryFragment : Fragment() { onItemClick = { course -> viewModel.discoveryCourseClicked(course.id, course.name) router.navigateToCourseDetail( - requireParentFragment().parentFragmentManager, + requireActivity().supportFragmentManager, course.id ) + }, + onRegisterClick = { + router.navigateToSignUp(parentFragmentManager, null) + }, + onSignInClick = { + router.navigateToSignIn(parentFragmentManager, null) + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStackImmediate() }) + LaunchedEffect(uiState) { + if (querySearch.isNotEmpty()) { + router.navigateToCourseSearch( + requireActivity().supportFragmentManager, querySearch + ) + arguments?.putString(ARG_SEARCH_QUERY, "") + } + } } } } + + companion object { + private const val ARG_SEARCH_QUERY = "query_search" + fun newInstance(querySearch: String): NativeDiscoveryFragment { + val fragment = NativeDiscoveryFragment() + fragment.arguments = bundleOf( + ARG_SEARCH_QUERY to querySearch + ) + return fragment + } + } } @@ -97,19 +192,26 @@ internal fun DiscoveryScreen( windowSize: WindowSize, state: DiscoveryUIState, uiMessage: UIMessage?, + apiHostUrl: String, canLoadMore: Boolean, refreshing: Boolean, hasInternetConnection: Boolean, + canShowBackButton: Boolean, + isUserLoggedIn: Boolean, + appUpgradeParameters: AppUpdateState.AppUpgradeParameters, onSearchClick: () -> Unit, onSwipeRefresh: () -> Unit, onReloadClick: () -> Unit, paginationCallback: () -> Unit, - onItemClick: (Course) -> Unit + onItemClick: (Course) -> Unit, + onRegisterClick: () -> Unit, + onSignInClick: () -> Unit, + onBackClick: () -> Unit, ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberLazyListState() val firstVisibleIndex = remember { - mutableStateOf(scrollState.firstVisibleItemIndex) + mutableIntStateOf(scrollState.firstVisibleItemIndex) } val pullRefreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) @@ -121,7 +223,23 @@ internal fun DiscoveryScreen( Scaffold( scaffoldState = scaffoldState, modifier = Modifier.fillMaxSize(), - backgroundColor = MaterialTheme.appColors.background + backgroundColor = MaterialTheme.appColors.background, + bottomBar = { + if (!isUserLoggedIn) { + Box( + modifier = Modifier + .padding( + horizontal = 16.dp, + vertical = 32.dp, + ) + ) { + AuthButtonsPanel( + onRegisterClick = onRegisterClick, + onSignInClick = onSignInClick + ) + } + } + } ) { val searchTabWidth by remember(key1 = windowSize) { @@ -156,11 +274,27 @@ internal fun DiscoveryScreen( HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + if (canShowBackButton) { + Box( + modifier = Modifier + .statusBarsPadding() + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + BackBtn( + modifier = Modifier.padding(end = 16.dp), + tint = MaterialTheme.appColors.primary + ) { + onBackClick() + } + } + } Column( Modifier .fillMaxSize() .padding(it) - .statusBarsInset(), + .statusBarsInset() + .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { Column( @@ -235,9 +369,10 @@ internal fun DiscoveryScreen( } items(state.courses) { course -> DiscoveryCourseItem( - course, + apiHostUrl = apiHostUrl, + course = course, windowSize = windowSize, - onClick = { courseId -> + onClick = { onItemClick(course) }) Divider() @@ -266,19 +401,49 @@ internal fun DiscoveryScreen( pullRefreshState, Modifier.align(Alignment.TopCenter) ) - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() + + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) { + when (appUpgradeParameters.appUpgradeEvent) { + is AppUpgradeEvent.UpgradeRecommendedEvent -> { + if (appUpgradeParameters.wasUpdateDialogClosed) { + AppUpgradeRecommendedBox( + modifier = Modifier.fillMaxWidth(), + onClick = appUpgradeParameters.onAppUpgradeRecommendedBoxClick + ) + } else { + if (!AppUpdateState.wasUpdateDialogDisplayed) { + AppUpdateState.wasUpdateDialogDisplayed = true + appUpgradeParameters.appUpgradeRecommendedDialog() + } + } } - ) + + is AppUpgradeEvent.UpgradeRequiredEvent -> { + if (!AppUpdateState.wasUpdateDialogDisplayed) { + AppUpdateState.wasUpdateDialogDisplayed = true + appUpgradeParameters.onAppUpgradeRequired() + } + } + + else -> {} + } + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth(), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onReloadClick() + } + ) + } } } } @@ -292,6 +457,7 @@ internal fun DiscoveryScreen( private fun CourseItemPreview() { OpenEdXTheme { DiscoveryCourseItem( + apiHostUrl = "", course = mockCourse, windowSize = WindowSize(WindowType.Compact, WindowType.Compact), onClick = {}) @@ -319,6 +485,7 @@ private fun DiscoveryScreenPreview() { ) ), uiMessage = null, + apiHostUrl = "", onSearchClick = {}, paginationCallback = {}, onSwipeRefresh = {}, @@ -326,7 +493,13 @@ private fun DiscoveryScreenPreview() { onReloadClick = {}, canLoadMore = false, refreshing = false, - hasInternetConnection = true + hasInternetConnection = true, + isUserLoggedIn = false, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), + onSignInClick = {}, + onRegisterClick = {}, + onBackClick = {}, + canShowBackButton = false ) } } @@ -352,6 +525,7 @@ private fun DiscoveryScreenTabletPreview() { ) ), uiMessage = null, + apiHostUrl = "", onSearchClick = {}, paginationCallback = {}, onSwipeRefresh = {}, @@ -359,7 +533,13 @@ private fun DiscoveryScreenTabletPreview() { onReloadClick = {}, canLoadMore = false, refreshing = false, - hasInternetConnection = true + hasInternetConnection = true, + isUserLoggedIn = true, + appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), + onSignInClick = {}, + onRegisterClick = {}, + onBackClick = {}, + canShowBackButton = false ) } } @@ -386,4 +566,4 @@ private val mockCourse = Course( startType = "startType", overview = "", isEnrolled = false -) \ No newline at end of file +) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt similarity index 74% rename from discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryViewModel.kt rename to discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt index a1352333e..0a2d5603c 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt @@ -3,21 +3,37 @@ package org.openedx.discovery.presentation import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import org.openedx.core.* +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.SingleEventLiveData +import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Course import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.discovery.domain.interactor.DiscoveryInteractor -import kotlinx.coroutines.launch -class DiscoveryViewModel( +class NativeDiscoveryViewModel( + private val config: Config, private val networkConnection: NetworkConnection, private val interactor: DiscoveryInteractor, private val resourceManager: ResourceManager, - private val analytics: DiscoveryAnalytics + private val analytics: DiscoveryAnalytics, + private val appUpgradeNotifier: AppUpgradeNotifier, + private val corePreferences: CorePreferences ) : BaseViewModel() { + val apiHostUrl get() = config.getApiHostURL() + val isUserLoggedIn get() = corePreferences.user != null + val canShowBackButton get() = config.isPreLoginExperienceEnabled() && !isUserLoggedIn + private val _uiState = MutableLiveData(DiscoveryUIState.Loading) val uiState: LiveData get() = _uiState @@ -34,6 +50,10 @@ class DiscoveryViewModel( val isUpdating: LiveData get() = _isUpdating + private val _appUpgradeEvent = MutableLiveData() + val appUpgradeEvent: LiveData + get() = _appUpgradeEvent + val hasInternetConnection: Boolean get() = networkConnection.isOnline() @@ -43,6 +63,7 @@ class DiscoveryViewModel( init { getCoursesList() + collectAppUpgradeEvent() } private fun loadCoursesInternal( @@ -136,6 +157,25 @@ class DiscoveryViewModel( } } + @OptIn(FlowPreview::class) + private fun collectAppUpgradeEvent() { + viewModelScope.launch { + appUpgradeNotifier.notifier + .debounce(100) + .collect { event -> + when (event) { + is AppUpgradeEvent.UpgradeRecommendedEvent -> { + _appUpgradeEvent.value = event + } + + is AppUpgradeEvent.UpgradeRequiredEvent -> { + _appUpgradeEvent.value = AppUpgradeEvent.UpgradeRequiredEvent + } + } + } + } + } + fun discoverySearchBarClickedEvent() { analytics.discoverySearchBarClickedEvent() } @@ -143,4 +183,4 @@ class DiscoveryViewModel( fun discoveryCourseClicked(courseId: String, courseName: String) { analytics.discoveryCourseClickedEvent(courseId, courseName) } -} \ No newline at end of file +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt new file mode 100644 index 000000000..a30d6075d --- /dev/null +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryFragment.kt @@ -0,0 +1,296 @@ +package org.openedx.discovery.presentation + +import android.annotation.SuppressLint +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import android.webkit.WebView +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.zIndex +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.presentation.catalog.CatalogWebViewScreen +import org.openedx.core.presentation.catalog.WebViewLink +import org.openedx.core.presentation.dialog.alert.ActionDialogFragment +import org.openedx.core.ui.ConnectionErrorView +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.windowSizeValue +import org.openedx.discovery.R +import org.openedx.core.R as CoreR + +class WebViewDiscoveryFragment : Fragment() { + + private val viewModel by viewModel() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + var hasInternetConnection by remember { + mutableStateOf(viewModel.hasInternetConnection) + } + + WebViewDiscoveryScreen( + windowSize = windowSize, + contentUrl = viewModel.discoveryUrl, + uriScheme = viewModel.uriScheme, + hasInternetConnection = hasInternetConnection, + checkInternetConnection = { + hasInternetConnection = viewModel.hasInternetConnection + }, + onWebPageUpdated = { url -> + viewModel.updateDiscoveryUrl(url) + }, + onUriClick = { param, authority -> + when (authority) { + WebViewLink.Authority.COURSE_INFO, + WebViewLink.Authority.PROGRAM_INFO -> { + viewModel.infoCardClicked( + fragmentManager = requireActivity().supportFragmentManager, + pathId = param, + infoType = authority.name + ) + } + + WebViewLink.Authority.EXTERNAL -> { + ActionDialogFragment.newInstance( + title = getString(CoreR.string.core_leaving_the_app), + message = getString( + CoreR.string.core_leaving_the_app_message, + getString(CoreR.string.platform_name) + ), + url = param, + ).show( + requireActivity().supportFragmentManager, + ActionDialogFragment::class.simpleName + ) + } + + else -> {} + } + } + ) + } + } + } +} + +@Composable +@SuppressLint("SetJavaScriptEnabled") +private fun WebViewDiscoveryScreen( + windowSize: WindowSize, + contentUrl: String, + uriScheme: String, + hasInternetConnection: Boolean, + checkInternetConnection: () -> Unit, + onWebPageUpdated: (String) -> Unit, + onUriClick: (String, WebViewLink.Authority) -> Unit +) { + val scaffoldState = rememberScaffoldState() + val configuration = LocalConfiguration.current + var isLoading by remember { mutableStateOf(true) } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier.fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background + ) { + val modifierScreenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + Modifier.widthIn(Dp.Unspecified, 560.dp) + } else { + Modifier.widthIn(Dp.Unspecified, 650.dp) + }, + compact = Modifier.fillMaxWidth() + ) + ) + } + + Column( + modifier = modifierScreenWidth + .fillMaxSize() + .padding(it) + .statusBarsInset() + .displayCutoutForLandscape(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Toolbar(label = stringResource(id = R.string.discovery_explore_the_catalog)) + + Surface { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + contentAlignment = Alignment.TopCenter + ) { + if (hasInternetConnection) { + DiscoveryWebView( + contentUrl = contentUrl, + uriScheme = uriScheme, + onWebPageLoaded = { isLoading = false }, + onWebPageUpdated = onWebPageUpdated, + onUriClick = onUriClick, + ) + } else { + ConnectionErrorView( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(MaterialTheme.appColors.background) + ) { + checkInternetConnection() + } + } + if (isLoading && hasInternetConnection) { + Box( + modifier = Modifier + .fillMaxSize() + .zIndex(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + } + } + } + } +} + +@Composable +@SuppressLint("SetJavaScriptEnabled") +private fun DiscoveryWebView( + contentUrl: String, + uriScheme: String, + onWebPageLoaded: () -> Unit, + onWebPageUpdated: (String) -> Unit, + onUriClick: (String, WebViewLink.Authority) -> Unit, +) { + val webView = CatalogWebViewScreen( + url = contentUrl, + uriScheme = uriScheme, + onWebPageLoaded = onWebPageLoaded, + onWebPageUpdated = onWebPageUpdated, + onUriClick = onUriClick, + ) + + AndroidView( + modifier = Modifier + .background(MaterialTheme.appColors.background), + factory = { + webView + } + ) + + HandleWebViewBackNavigation(webView = webView) +} + +@Composable +private fun HandleWebViewBackNavigation( + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, + webView: WebView? +) { + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + + val onBackPressedCallback = remember { + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (webView?.canGoBack() == true) { + webView.goBack() + } else { + this.isEnabled = false + backDispatcher?.onBackPressed() + } + } + } + } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + onBackPressedCallback.isEnabled = true + } else if (event == Lifecycle.Event.ON_PAUSE) { + onBackPressedCallback.isEnabled = false + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + DisposableEffect(backDispatcher) { + backDispatcher?.addCallback(onBackPressedCallback) + onDispose { + onBackPressedCallback.remove() + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun WebViewDiscoveryScreenPreview() { + OpenEdXTheme { + WebViewDiscoveryScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + contentUrl = "https://www.example.com/", + uriScheme = "", + hasInternetConnection = false, + checkInternetConnection = {}, + onWebPageUpdated = {}, + onUriClick = { _, _ -> }, + ) + } +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt new file mode 100644 index 000000000..a25bc801e --- /dev/null +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt @@ -0,0 +1,40 @@ +package org.openedx.discovery.presentation + +import androidx.fragment.app.FragmentManager +import org.openedx.core.BaseViewModel +import org.openedx.core.config.Config +import org.openedx.core.system.connection.NetworkConnection + +class WebViewDiscoveryViewModel( + private val config: Config, + private val networkConnection: NetworkConnection, + private val router: DiscoveryRouter, +) : BaseViewModel() { + + val uriScheme: String get() = config.getUriScheme() + + val webViewConfig get() = config.getDiscoveryConfig().webViewConfig + + private var _discoveryUrl = webViewConfig.baseUrl + val discoveryUrl: String + get() = _discoveryUrl + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + fun updateDiscoveryUrl(url: String) { + if (url.isNotEmpty()) { + _discoveryUrl = url + } + } + + fun infoCardClicked(fragmentManager: FragmentManager, pathId: String, infoType: String) { + if (pathId.isNotEmpty() && infoType.isNotEmpty()) { + router.navigateToCourseInfo( + fragmentManager, + pathId, + infoType + ) + } + } +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt index b81e6bea3..9329887fc 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt @@ -4,17 +4,40 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -24,6 +47,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -32,17 +56,28 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.UIMessage import org.openedx.core.domain.model.Course import org.openedx.core.domain.model.Media -import org.openedx.core.ui.* +import org.openedx.core.ui.AuthButtonsPanel +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.DiscoveryCourseItem +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.SearchBar +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue import org.openedx.discovery.presentation.DiscoveryRouter -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.discovery.R as discoveryR class CourseSearchFragment : Fragment() { @@ -69,13 +104,17 @@ class CourseSearchFragment : Fragment() { val uiMessage by viewModel.uiMessage.observeAsState() val canLoadMore by viewModel.canLoadMore.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) + val querySearch = arguments?.getString(ARG_SEARCH_QUERY, "") ?: "" CourseSearchScreen( windowSize = windowSize, state = uiState, uiMessage = uiMessage, + apiHostUrl = viewModel.apiHostUrl, canLoadMore = canLoadMore, refreshing = refreshing, + querySearch = querySearch, + isUserLoggedIn = viewModel.isUserLoggedIn, onBackClick = { requireActivity().supportFragmentManager.popBackStack() }, @@ -93,12 +132,28 @@ class CourseSearchFragment : Fragment() { requireActivity().supportFragmentManager, it ) - } + }, + onRegisterClick = { + router.navigateToSignUp(parentFragmentManager, null) + }, + onSignInClick = { + router.navigateToSignIn(parentFragmentManager, null) + }, ) } } } + companion object { + private const val ARG_SEARCH_QUERY = "query_search" + fun newInstance(querySearch: String): CourseSearchFragment { + val fragment = CourseSearchFragment() + fragment.arguments = bundleOf( + ARG_SEARCH_QUERY to querySearch + ) + return fragment + } + } } @@ -108,13 +163,18 @@ private fun CourseSearchScreen( windowSize: WindowSize, state: CourseSearchUIState, uiMessage: UIMessage?, + apiHostUrl: String, canLoadMore: Boolean, refreshing: Boolean, + querySearch: String, + isUserLoggedIn: Boolean, onBackClick: () -> Unit, onSearchTextChanged: (String) -> Unit, onSwipeRefresh: () -> Unit, paginationCallback: () -> Unit, - onItemClick: (String) -> Unit + onItemClick: (String) -> Unit, + onRegisterClick: () -> Unit, + onSignInClick: () -> Unit, ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberLazyListState() @@ -125,7 +185,12 @@ private fun CourseSearchScreen( rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue("")) + mutableStateOf( + TextFieldValue( + text = querySearch, + selection = TextRange(querySearch.length) + ) + ) } val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -141,7 +206,23 @@ private fun CourseSearchScreen( modifier = Modifier .fillMaxSize() .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background + backgroundColor = MaterialTheme.appColors.background, + bottomBar = { + if (!isUserLoggedIn) { + Box( + modifier = Modifier + .padding( + horizontal = 16.dp, + vertical = 32.dp, + ) + ) { + AuthButtonsPanel( + onRegisterClick = onRegisterClick, + onSignInClick = onSignInClick + ) + } + } + } ) { val screenWidth by remember(key1 = windowSize) { @@ -285,9 +366,11 @@ private fun CourseSearchScreen( } } } + is CourseSearchUIState.Courses -> { items(state.courses) { course -> DiscoveryCourseItem( + apiHostUrl = apiHostUrl, course, windowSize = windowSize, onClick = { courseId -> @@ -323,6 +406,9 @@ private fun CourseSearchScreen( } } } + LaunchedEffect(rememberSaveable { true }) { + onSearchTextChanged(querySearch) + } } @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @@ -334,13 +420,18 @@ fun CourseSearchScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), state = CourseSearchUIState.Courses(listOf(mockCourse, mockCourse), 2), uiMessage = null, + apiHostUrl = "", canLoadMore = false, refreshing = false, + querySearch = "", + isUserLoggedIn = true, onBackClick = {}, onSearchTextChanged = {}, onSwipeRefresh = {}, paginationCallback = {}, - onItemClick = {} + onItemClick = {}, + onSignInClick = {}, + onRegisterClick = {}, ) } } @@ -354,13 +445,18 @@ fun CourseSearchScreenTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), state = CourseSearchUIState.Courses(listOf(mockCourse, mockCourse), 2), uiMessage = null, + apiHostUrl = "", canLoadMore = false, refreshing = false, + querySearch = "", + isUserLoggedIn = false, onBackClick = {}, onSearchTextChanged = {}, onSwipeRefresh = {}, paginationCallback = {}, - onItemClick = {} + onItemClick = {}, + onSignInClick = {}, + onRegisterClick = {}, ) } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt index 3a8d2d43d..3dbb7c6b9 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt @@ -3,27 +3,34 @@ package org.openedx.discovery.presentation.search import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Course import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.DiscoveryAnalytics -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.launch class CourseSearchViewModel( + private val config: Config, + private val corePreferences: CorePreferences, private val interactor: DiscoveryInteractor, private val resourceManager: ResourceManager, private val analytics: DiscoveryAnalytics ) : BaseViewModel() { + val apiHostUrl get() = config.getApiHostURL() + val isUserLoggedIn get() = corePreferences.user != null + private val _uiState = MutableLiveData(CourseSearchUIState.Courses(emptyList(), 0)) val uiState: LiveData diff --git a/discovery/src/main/res/values/strings.xml b/discovery/src/main/res/values/strings.xml index 543ce04a4..b6a6c16b8 100644 --- a/discovery/src/main/res/values/strings.xml +++ b/discovery/src/main/res/values/strings.xml @@ -1,10 +1,11 @@ - + Discover Discover new Let\'s find something new for you Search results Start typing to find the course + Explore the catalog Found %s courses on your request @@ -14,4 +15,4 @@ Found %s courses on your request Found %s courses on your request - \ No newline at end of file + diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/DiscoveryViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt similarity index 78% rename from discovery/src/test/java/org/openedx/discovery/presentation/DiscoveryViewModelTest.kt rename to discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt index deb539ef8..3548b33ae 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/DiscoveryViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt @@ -1,19 +1,14 @@ package org.openedx.discovery.presentation import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.CourseList -import org.openedx.core.domain.model.Pagination -import org.openedx.core.system.ResourceManager -import org.openedx.core.system.connection.NetworkConnection -import org.openedx.discovery.domain.interactor.DiscoveryInteractor import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.* import org.junit.After import org.junit.Assert.assertEquals @@ -21,10 +16,20 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.domain.model.CourseList +import org.openedx.core.domain.model.Pagination +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.discovery.domain.interactor.DiscoveryInteractor import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) -class DiscoveryViewModelTest { +class NativeDiscoveryViewModelTest { @get:Rule val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() @@ -32,10 +37,13 @@ class DiscoveryViewModelTest { private val dispatcher = StandardTestDispatcher() + private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() private val networkConnection = mockk() private val analytics = mockk() + private val appUpgradeNotifier = mockk() + private val corePreferences = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -45,6 +53,10 @@ class DiscoveryViewModelTest { Dispatchers.setMain(dispatcher) 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 { corePreferences.user } returns null + every { config.getApiHostURL() } returns "http://localhost:8000" + every { config.isPreLoginExperienceEnabled() } returns false } @After @@ -54,14 +66,14 @@ class DiscoveryViewModelTest { @Test fun `getCoursesList no internet connection`() = runTest { - val viewModel = - DiscoveryViewModel(networkConnection, interactor, resourceManager, analytics) + val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) every { networkConnection.isOnline() } returns true coEvery { interactor.getCoursesList(any(), any(), any()) } throws UnknownHostException() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCoursesList(any(), any(), any()) } coVerify(exactly = 0) { interactor.getCoursesListFromCache() } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -71,8 +83,7 @@ class DiscoveryViewModelTest { @Test fun `getCoursesList unknown exception`() = runTest { - val viewModel = - DiscoveryViewModel(networkConnection, interactor, resourceManager, analytics) + val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) every { networkConnection.isOnline() } returns true coEvery { interactor.getCoursesList(any(), any(), any()) } throws Exception() advanceUntilIdle() @@ -88,8 +99,7 @@ class DiscoveryViewModelTest { @Test fun `getCoursesList from cache`() = runTest { - val viewModel = - DiscoveryViewModel(networkConnection, interactor, resourceManager, analytics) + val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) every { networkConnection.isOnline() } returns false coEvery { interactor.getCoursesListFromCache() } returns emptyList() advanceUntilIdle() @@ -104,8 +114,7 @@ class DiscoveryViewModelTest { @Test fun `getCoursesList from network with next page`() = runTest { - val viewModel = - DiscoveryViewModel(networkConnection, interactor, resourceManager, analytics) + val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) every { networkConnection.isOnline() } returns true coEvery { interactor.getCoursesList(any(), any(), any()) } returns CourseList( Pagination( @@ -127,8 +136,7 @@ class DiscoveryViewModelTest { @Test fun `getCoursesList from network without next page`() = runTest { - val viewModel = - DiscoveryViewModel(networkConnection, interactor, resourceManager, analytics) + val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) every { networkConnection.isOnline() } returns true coEvery { interactor.getCoursesList(any(), any(), any()) } returns CourseList( Pagination( @@ -151,10 +159,8 @@ class DiscoveryViewModelTest { @Test fun `updateData no internet connection`() = runTest { - val viewModel = - DiscoveryViewModel(networkConnection, interactor, resourceManager, analytics) + val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCoursesList(any(), any(), any()) } throws UnknownHostException() viewModel.updateData() advanceUntilIdle() @@ -170,10 +176,8 @@ class DiscoveryViewModelTest { @Test fun `updateData unknown exception`() = runTest { - val viewModel = - DiscoveryViewModel(networkConnection, interactor, resourceManager, analytics) + val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCoursesList(any(), any(), any()) } throws Exception() viewModel.updateData() advanceUntilIdle() @@ -189,10 +193,8 @@ class DiscoveryViewModelTest { @Test fun `updateData success with next page`() = runTest { - val viewModel = - DiscoveryViewModel(networkConnection, interactor, resourceManager, analytics) + val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCoursesList(any(), any(), any()) } returns CourseList( Pagination( 10, @@ -214,10 +216,8 @@ class DiscoveryViewModelTest { @Test fun `updateData success without next page`() = runTest { - val viewModel = - DiscoveryViewModel(networkConnection, interactor, resourceManager, analytics) + val viewModel = NativeDiscoveryViewModel(config, networkConnection, interactor, resourceManager, analytics, appUpgradeNotifier, corePreferences) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCoursesList(any(), any(), any()) } returns CourseList( Pagination( 10, @@ -237,5 +237,4 @@ class DiscoveryViewModelTest { assert(viewModel.uiState.value is DiscoveryUIState.Courses) } - -} \ No newline at end of file +} diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt index 615e17f58..2acf8e123 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt @@ -1,28 +1,35 @@ package org.openedx.discovery.presentation.search import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.Course -import org.openedx.core.domain.model.CourseList -import org.openedx.core.domain.model.Media -import org.openedx.core.domain.model.Pagination -import org.openedx.core.system.ResourceManager -import org.openedx.discovery.domain.interactor.DiscoveryInteractor -import org.openedx.discovery.presentation.DiscoveryAnalytics import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify -import kotlinx.coroutines.* -import kotlinx.coroutines.test.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.Course +import org.openedx.core.domain.model.CourseList +import org.openedx.core.domain.model.Media +import org.openedx.core.domain.model.Pagination +import org.openedx.core.system.ResourceManager +import org.openedx.discovery.domain.interactor.DiscoveryInteractor +import org.openedx.discovery.presentation.DiscoveryAnalytics import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -34,6 +41,8 @@ class CourseSearchViewModelTest { private val dispatcher = UnconfinedTestDispatcher() + private val config = mockk() + private val corePreferences = mockk() private val resourceManager = mockk() private val interactor = mockk() private val analytics = mockk() @@ -74,6 +83,7 @@ class CourseSearchViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { config.getApiHostURL() } returns "http://localhost:8000" } @After @@ -83,7 +93,8 @@ class CourseSearchViewModelTest { @Test fun `search empty query`() = runTest { - val viewModel = CourseSearchViewModel(interactor, resourceManager, analytics) + val viewModel = + CourseSearchViewModel(config, corePreferences, interactor, resourceManager, analytics) viewModel.search("") advanceUntilIdle() @@ -97,7 +108,8 @@ class CourseSearchViewModelTest { @Test fun `search query no internet connection exception`() = runTest { - val viewModel = CourseSearchViewModel(interactor, resourceManager, analytics) + val viewModel = + CourseSearchViewModel(config, corePreferences, interactor, resourceManager, analytics) coEvery { interactor.getCoursesListByQuery(any(), any()) } throws UnknownHostException() viewModel.search("course") @@ -112,7 +124,8 @@ class CourseSearchViewModelTest { @Test fun `search query unknown exception`() = runTest { - val viewModel = CourseSearchViewModel(interactor, resourceManager, analytics) + val viewModel = + CourseSearchViewModel(config, corePreferences, interactor, resourceManager, analytics) coEvery { interactor.getCoursesListByQuery(any(), any()) } throws Exception() viewModel.search("course") @@ -127,7 +140,8 @@ class CourseSearchViewModelTest { @Test fun `search query success without next page`() = runTest { - val viewModel = CourseSearchViewModel(interactor, resourceManager, analytics) + val viewModel = + CourseSearchViewModel(config, corePreferences, interactor, resourceManager, analytics) coEvery { interactor.getCoursesListByQuery(any(), any()) } returns CourseList( Pagination( 10, @@ -152,7 +166,8 @@ class CourseSearchViewModelTest { @Test fun `search query success with next page and fetch`() = runTest { - val viewModel = CourseSearchViewModel(interactor, resourceManager, analytics) + val viewModel = + CourseSearchViewModel(config, corePreferences, interactor, resourceManager, analytics) coEvery { interactor.getCoursesListByQuery(any(), eq(1)) } returns CourseList( Pagination( 10, @@ -186,7 +201,8 @@ class CourseSearchViewModelTest { @Test fun `search query success with next page and fetch, update`() = runTest { - val viewModel = CourseSearchViewModel(interactor, resourceManager, analytics) + val viewModel = + CourseSearchViewModel(config, corePreferences, interactor, resourceManager, analytics) coEvery { interactor.getCoursesListByQuery(any(), eq(1)) } returns CourseList( Pagination( 10, @@ -221,7 +237,8 @@ class CourseSearchViewModelTest { @Test fun `search query update in empty state`() = runTest { - val viewModel = CourseSearchViewModel(interactor, resourceManager, analytics) + val viewModel = + CourseSearchViewModel(config, corePreferences, interactor, resourceManager, analytics) coEvery { interactor.getCoursesListByQuery(any(), eq(1)) } returns CourseList( Pagination( 10, diff --git a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt index 6969522fe..ebc911425 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt @@ -98,7 +98,7 @@ interface DiscussionApi { @Query("requested_fields") requestedFields: List = listOf("profile_image") ): CommentsResponse - @POST("/mobile_api_extensions/discussion/v1/comments/") + @POST("/api/discussion/v1/comments/") suspend fun createComment( @Body commentBody: CommentBody ) : CommentResult diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionRouter.kt b/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionRouter.kt index 71386a645..54f519004 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionRouter.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionRouter.kt @@ -37,4 +37,8 @@ interface DiscussionRouter { courseId: String ) + fun navigateToAnothersProfile( + fm: FragmentManager, + username: String + ) } \ No newline at end of file diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt index 1a5b1315a..02f950bb6 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt @@ -27,6 +27,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource @@ -56,6 +58,7 @@ import org.openedx.discussion.presentation.ui.ThreadMainItem import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.discussion.R class DiscussionCommentsFragment : Fragment() { @@ -121,6 +124,11 @@ class DiscussionCommentsFragment : Fragment() { requireActivity().supportFragmentManager, it, viewModel.thread.closed ) }, + onUserPhotoClick = { username -> + router.navigateToAnothersProfile( + requireActivity().supportFragmentManager, username + ) + }, onAddResponseClick = { viewModel.createComment(it) }, @@ -167,7 +175,8 @@ private fun DiscussionCommentsScreen( onItemClick: (String, String, Boolean) -> Unit, onCommentClick: (DiscussionComment) -> Unit, onAddResponseClick: (String) -> Unit, - onBackClick: () -> Unit + onBackClick: () -> Unit, + onUserPhotoClick: (String) -> Unit ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberLazyListState() @@ -200,6 +209,8 @@ private fun DiscussionCommentsScreen( .navigationBarsPadding(), backgroundColor = MaterialTheme.appColors.background ) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current val screenWidth by remember(key1 = windowSize) { mutableStateOf( @@ -236,7 +247,8 @@ private fun DiscussionCommentsScreen( ) { Box( modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + .displayCutoutForLandscape(), contentAlignment = Alignment.CenterStart, ) { BackBtn { @@ -271,6 +283,7 @@ private fun DiscussionCommentsScreen( Modifier .then(screenWidth) .weight(1f) + .displayCutoutForLandscape() .background(MaterialTheme.appColors.background), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -287,7 +300,11 @@ private fun DiscussionCommentsScreen( thread = uiState.thread, onClick = { action, bool -> onItemClick(action, uiState.thread.id, bool) - }) + }, + onUserPhotoClick = { username -> + onUserPhotoClick(username) + } + ) } if (uiState.commentsData.isNotEmpty()) { item { @@ -320,6 +337,9 @@ private fun DiscussionCommentsScreen( }, onAddCommentClick = { onCommentClick(comment) + }, + onUserPhotoClick = { + onUserPhotoClick(comment.author) }) } item { @@ -350,7 +370,8 @@ private fun DiscussionCommentsScreen( .then(screenWidth) .heightIn(84.dp, Dp.Unspecified) .padding(top = 16.dp, bottom = 24.dp) - .padding(horizontal = 24.dp), + .padding(horizontal = 24.dp) + .displayCutoutForLandscape(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -367,7 +388,7 @@ private fun DiscussionCommentsScreen( maxLines = 3, placeholder = { Text( - text = stringResource(id = org.openedx.discussion.R.string.discussion_add_response), + text = stringResource(id = R.string.discussion_add_response), color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.labelLarge, ) @@ -385,6 +406,8 @@ private fun DiscussionCommentsScreen( .clip(CircleShape) .background(sendButtonColor) .clickable { + keyboardController?.hide() + focusManager.clearFocus() if (responseValue.isNotEmpty()) { onAddResponseClick(responseValue.trim()) responseValue = "" @@ -394,8 +417,8 @@ private fun DiscussionCommentsScreen( ) { Icon( modifier = Modifier.padding(7.dp), - painter = painterResource(id = org.openedx.discussion.R.drawable.discussion_ic_send), - contentDescription = null, + painter = painterResource(id = R.drawable.discussion_ic_send), + contentDescription = stringResource(id = R.string.discussion_add_response), tint = iconButtonColor ) } @@ -403,6 +426,7 @@ private fun DiscussionCommentsScreen( } } } + is DiscussionCommentsUIState.Loading -> { Box( Modifier @@ -450,7 +474,8 @@ private fun DiscussionCommentsScreenPreview() { onBackClick = {}, scrollToBottom = false, refreshing = false, - onSwipeRefresh = {} + onSwipeRefresh = {}, + onUserPhotoClick = {} ) } } @@ -480,7 +505,8 @@ private fun DiscussionCommentsScreenTabletPreview() { onBackClick = {}, scrollToBottom = false, refreshing = false, - onSwipeRefresh = {} + onSwipeRefresh = {}, + onUserPhotoClick = {} ) } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt index 169b65b97..cafcf1cb2 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt @@ -27,6 +27,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource @@ -39,6 +41,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.koin.android.ext.android.inject import org.openedx.core.UIMessage import org.openedx.core.domain.model.ProfileImage import org.openedx.core.extension.TextConverter @@ -51,8 +56,7 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.presentation.comments.DiscussionCommentsFragment import org.openedx.discussion.presentation.ui.CommentMainItem -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf +import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.R as discussionR class DiscussionResponsesFragment : Fragment() { @@ -61,6 +65,8 @@ class DiscussionResponsesFragment : Fragment() { parametersOf(requireArguments().parcelable(ARG_COMMENT)) } + private val router by inject() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) @@ -118,6 +124,11 @@ class DiscussionResponsesFragment : Fragment() { }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() + }, + onUserPhotoClick = { username -> + router.navigateToAnothersProfile( + requireActivity().supportFragmentManager, username + ) } ) } @@ -156,9 +167,13 @@ private fun DiscussionResponsesScreen( onItemClick: (String, String, Boolean) -> Unit, addCommentClick: (String) -> Unit, onBackClick: () -> Unit, + onUserPhotoClick: (String) -> Unit ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberLazyListState() + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val firstVisibleIndex = remember { mutableStateOf(scrollState.firstVisibleItemIndex) } @@ -232,7 +247,8 @@ private fun DiscussionResponsesScreen( ) { Box( modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + .displayCutoutForLandscape(), contentAlignment = Alignment.CenterStart, ) { BackBtn { @@ -266,7 +282,8 @@ private fun DiscussionResponsesScreen( Column( Modifier .fillMaxWidth() - .weight(1f), + .weight(1f) + .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { LazyColumn( @@ -290,7 +307,11 @@ private fun DiscussionResponsesScreen( uiState.mainComment.id, bool ) - }) + }, + onUserPhotoClick = {username -> + onUserPhotoClick(username) + } + ) } if (uiState.mainComment.childCount > 0) { item { @@ -332,7 +353,11 @@ private fun DiscussionResponsesScreen( comment = comment, onClick = { action, commentId, bool -> onItemClick(action, commentId, bool) - }) + }, + onUserPhotoClick = {username -> + onUserPhotoClick(username) + } + ) } } item { @@ -364,7 +389,8 @@ private fun DiscussionResponsesScreen( .then(screenWidth) .heightIn(84.dp, Dp.Unspecified) .padding(top = 16.dp, bottom = 24.dp) - .padding(horizontal = 24.dp), + .padding(horizontal = 24.dp) + .displayCutoutForLandscape(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -399,6 +425,8 @@ private fun DiscussionResponsesScreen( .clip(CircleShape) .background(sendButtonColor) .clickable { + keyboardController?.hide() + focusManager.clearFocus() if (commentValue.isNotEmpty()) { addCommentClick(commentValue.trim()) commentValue = "" @@ -464,7 +492,8 @@ private fun DiscussionResponsesScreenPreview() { }, onBackClick = {}, - isClosed = false + isClosed = false, + onUserPhotoClick = {} ) } } @@ -494,7 +523,8 @@ private fun DiscussionResponsesScreenTabletPreview() { }, onBackClick = {}, - isClosed = false + isClosed = false, + onUserPhotoClick = {} ) } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt index 9cffcd75a..dda5a41f4 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt @@ -236,7 +236,8 @@ private fun DiscussionAddThreadScreen( modifier = Modifier .fillMaxSize() .padding(it) - .statusBarsInset(), + .statusBarsInset() + .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter ) { Column( diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt index 7922919fa..99bf4f26e 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt @@ -10,9 +10,10 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.* - import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -24,7 +25,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.ViewCompositionStrategy @@ -39,6 +39,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.openedx.core.FragmentViewType import org.openedx.core.UIMessage import org.openedx.core.extension.TextConverter @@ -47,10 +51,6 @@ import org.openedx.core.ui.theme.* import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.ui.ThreadItem -import kotlinx.coroutines.launch -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf import org.openedx.discussion.R as discussionR class DiscussionThreadsFragment : Fragment() { @@ -332,6 +332,7 @@ private fun DiscussionThreadsScreen( modifier = Modifier .fillMaxSize() .padding(it) + .displayCutoutForLandscape() .then(statusBarInsets), contentAlignment = Alignment.TopCenter ) { @@ -483,7 +484,7 @@ private fun DiscussionThreadsScreen( Icon( modifier = Modifier.size(16.dp), painter = painterResource(id = discussionR.drawable.discussion_ic_add_comment), - contentDescription = null, + contentDescription = stringResource(id = discussionR.string.discussion_add_comment), tint = MaterialTheme.appColors.buttonText ) } @@ -516,69 +517,69 @@ private fun DiscussionThreadsScreen( } } } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopStart + val noDiscussionsScrollState = rememberScrollState() + Column( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .verticalScroll(noDiscussionsScrollState) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { Text( modifier = Modifier - .padding(start = 24.dp, top = 32.dp), + .fillMaxWidth(), text = title, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleLarge ) - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - modifier = Modifier.size(100.dp), - painter = painterResource(id = discussionR.drawable.discussion_ic_empty), - contentDescription = null, - tint = MaterialTheme.appColors.textPrimary - ) - Spacer(Modifier.height(36.dp)) - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(discussionR.string.discussion_no_yet), - style = MaterialTheme.appTypography.titleLarge, - color = MaterialTheme.appColors.textPrimary, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(12.dp)) - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(discussionR.string.discussion_click_button_create_discussion), - style = MaterialTheme.appTypography.bodyLarge, - color = MaterialTheme.appColors.textPrimary, - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(40.dp)) - OpenEdXOutlinedButton( - modifier = Modifier - .widthIn(184.dp, Dp.Unspecified), - text = stringResource(id = discussionR.string.discussion_create_post), - onClick = { - onCreatePostClick() - }, - content = { - Icon( - painter = painterResource(id = discussionR.drawable.discussion_ic_add_comment), - contentDescription = null, - tint = MaterialTheme.appColors.primary - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(id = discussionR.string.discussion_create_post), - color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge - ) - }, - borderColor = MaterialTheme.appColors.primary, - textColor = MaterialTheme.appColors.primary - ) - } + Spacer(modifier = Modifier.height(20.dp)) + Icon( + modifier = Modifier.size(100.dp), + painter = painterResource(id = discussionR.drawable.discussion_ic_empty), + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary + ) + Spacer(Modifier.height(36.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(discussionR.string.discussion_no_yet), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(12.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(discussionR.string.discussion_click_button_create_discussion), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(40.dp)) + OpenEdXOutlinedButton( + modifier = Modifier + .widthIn(184.dp, Dp.Unspecified), + text = stringResource(id = discussionR.string.discussion_create_post), + onClick = { + onCreatePostClick() + }, + content = { + Icon( + painter = painterResource(id = discussionR.drawable.discussion_ic_add_comment), + contentDescription = null, + tint = MaterialTheme.appColors.primary + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(id = discussionR.string.discussion_create_post), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge + ) + }, + borderColor = MaterialTheme.appColors.primary, + textColor = MaterialTheme.appColors.primary + ) } } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt index 6d2d7d4f1..7d386a2e8 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt @@ -24,13 +24,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.openedx.core.FragmentViewType import org.openedx.core.UIMessage import org.openedx.core.ui.* @@ -42,9 +44,6 @@ import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.ui.ThreadItemCategory import org.openedx.discussion.presentation.ui.TopicItem -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf import org.openedx.discussion.R as discussionR class DiscussionTopicsFragment : Fragment() { @@ -97,9 +96,6 @@ class DiscussionTopicsFragment : Fragment() { requireActivity().supportFragmentManager, viewModel.courseId ) - }, - onBackClick = { - requireActivity().supportFragmentManager.popBackStack() } ) } @@ -136,8 +132,7 @@ private fun DiscussionTopicsScreen( refreshing: Boolean, onSearchClick: () -> Unit, onSwipeRefresh: () -> Unit, - onItemClick: (String, String, String) -> Unit, - onBackClick: () -> Unit + onItemClick: (String, String, String) -> Unit ) { val scaffoldState = rememberScaffoldState() val context = LocalContext.current @@ -192,45 +187,20 @@ private fun DiscussionTopicsScreen( modifier = Modifier .fillMaxSize() .padding(it) - .statusBarsInset(), + .statusBarsInset() + .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter ) { Column(screenWidth) { - Column( + StaticSearchBar( modifier = Modifier + .height(48.dp) + .then(searchTabWidth) + .padding(horizontal = contentPaddings) .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Box( - modifier = Modifier - .fillMaxWidth(), - contentAlignment = Alignment.CenterStart - ) { - Text( - modifier = Modifier - .fillMaxWidth(), - text = stringResource(id = discussionR.string.discussion_discussions), - color = MaterialTheme.appColors.textPrimary, - textAlign = TextAlign.Center, - style = MaterialTheme.appTypography.titleMedium - ) - - BackBtn { - onBackClick() - } - } - Spacer(modifier = Modifier.height(16.dp)) - StaticSearchBar( - modifier = Modifier - .height(48.dp) - .then(searchTabWidth) - .padding(horizontal = contentPaddings), - text = stringResource(id = discussionR.string.discussion_search_all_posts), - onClick = onSearchClick - ) - - } + text = stringResource(id = discussionR.string.discussion_search_all_posts), + onClick = onSearchClick + ) Surface( modifier = Modifier.padding(top = 10.dp), color = MaterialTheme.appColors.background, @@ -353,7 +323,6 @@ private fun DiscussionTopicsScreenPreview() { uiMessage = null, refreshing = false, onItemClick = { _, _, _ -> }, - onBackClick = {}, onSwipeRefresh = {}, onSearchClick = {} ) @@ -371,7 +340,6 @@ private fun DiscussionTopicsScreenTabletPreview() { uiMessage = null, refreshing = false, onItemClick = { _, _, _ -> }, - onBackClick = {}, onSwipeRefresh = {}, onSearchClick = {} ) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt index 0d146692a..285b01d66 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt @@ -53,6 +53,7 @@ fun ThreadMainItem( modifier: Modifier, thread: org.openedx.discussion.domain.model.Thread, onClick: (String, Boolean) -> Unit, + onUserPhotoClick: (String) -> Unit ) { val profileImageUrl = if (thread.users?.get(thread.author)?.image?.hasImage == true) { thread.users[thread.author]?.image?.imageUrlFull @@ -93,14 +94,25 @@ fun ThreadMainItem( .error(org.openedx.core.R.drawable.core_ic_default_profile_picture) .placeholder(org.openedx.core.R.drawable.core_ic_default_profile_picture) .build(), - contentDescription = null, + contentDescription = stringResource(id = org.openedx.core.R.string.core_accessibility_user_profile_image, thread.author), modifier = Modifier .size(48.dp) .clip(MaterialTheme.appShapes.material.medium) + .clickable { + if (thread.author.isNotEmpty()) { + onUserPhotoClick(thread.author) + } + } ) Spacer(Modifier.width(16.dp)) Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .clickable { + if (thread.author.isNotEmpty()) { + onUserPhotoClick(thread.author) + } + }, verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( @@ -172,6 +184,7 @@ fun CommentItem( shape: Shape = MaterialTheme.appShapes.cardShape, onClick: (String, String, Boolean) -> Unit, onAddCommentClick: () -> Unit = {}, + onUserPhotoClick: (String) -> Unit ) { val profileImageUrl = if (comment.profileImage?.hasImage == true) { comment.profileImage.imageUrlFull @@ -232,15 +245,21 @@ fun CommentItem( .error(org.openedx.core.R.drawable.core_ic_default_profile_picture) .placeholder(org.openedx.core.R.drawable.core_ic_default_profile_picture) .build(), - contentDescription = null, + contentDescription = stringResource(id = org.openedx.core.R.string.core_accessibility_user_profile_image, comment.author), modifier = Modifier .size(32.dp) .clip(CircleShape) - + .clickable { + onUserPhotoClick(comment.author) + } ) Spacer(Modifier.width(12.dp)) Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .clickable { + onUserPhotoClick(comment.author) + }, verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( @@ -323,6 +342,7 @@ fun CommentMainItem( internalPadding: Dp = 16.dp, comment: DiscussionComment, onClick: (String, String, Boolean) -> Unit, + onUserPhotoClick: (String) -> Unit ) { val profileImageUrl = if (comment.profileImage?.hasImage == true) { comment.profileImage.imageUrlFull @@ -375,15 +395,21 @@ fun CommentMainItem( .error(org.openedx.core.R.drawable.core_ic_default_profile_picture) .placeholder(org.openedx.core.R.drawable.core_ic_default_profile_picture) .build(), - contentDescription = null, + contentDescription = stringResource(id = org.openedx.core.R.string.core_accessibility_user_profile_image, comment.author), modifier = Modifier .size(32.dp) .clip(CircleShape) - + .clickable { + onUserPhotoClick(comment.author) + } ) Spacer(Modifier.width(12.dp)) Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .clickable { + onUserPhotoClick(comment.author) + }, verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( @@ -478,8 +504,7 @@ fun ThreadItem( text = textType, painter = icon, color = MaterialTheme.appColors.textPrimaryVariant, - textStyle = MaterialTheme.appTypography.labelSmall, - onClick = {}) + textStyle = MaterialTheme.appTypography.labelSmall) if (thread.unreadCommentCount > 0 && !thread.read) { Row( modifier = Modifier, @@ -537,8 +562,7 @@ fun ThreadItem( ), painter = painterResource(id = R.drawable.discussion_ic_responses), color = MaterialTheme.appColors.textAccent, - textStyle = MaterialTheme.appTypography.labelLarge, - onClick = {}) + textStyle = MaterialTheme.appTypography.labelLarge) } } @@ -642,9 +666,9 @@ private fun CommentItemPreview() { CommentItem( modifier = Modifier.fillMaxWidth(), comment = mockComment, - onClick = { _, _, _ -> - - }) + onClick = { _, _, _ -> }, + onUserPhotoClick = {} + ) } } @@ -653,9 +677,10 @@ private fun CommentItemPreview() { private fun ThreadMainItemPreview() { ThreadMainItem( modifier = Modifier.fillMaxWidth(), - thread = mockThread, onClick = { _, _ -> - - }) + thread = mockThread, + onClick = { _, _ -> }, + onUserPhotoClick = {} + ) } private val mockComment = DiscussionComment( diff --git a/docs/0001-strategy-for-data-streams.rst b/docs/0001-strategy-for-data-streams.rst new file mode 100644 index 000000000..9fb98ac10 --- /dev/null +++ b/docs/0001-strategy-for-data-streams.rst @@ -0,0 +1,49 @@ +Title: Strategy for asynchronous data streams in the OpenEdx Project +================================================== +Date: 14 November 2023 + +Status +------ +Accepted + +Context +------ +In the OpenEdx project, we are developing a mobile application using a Kotlin language for Android +users. To ensure optimal support of the application, we need to make a decision regarding which +asynchronous data streams will be used for the future Android app development. This document +outlines the decision to support native Kotlin based StateFlow and SharedFlow. + +Decision +------ +We decide to use StateFlow and SharedFlow for asynchronous data streams management between UI and view +models. All new features should use flows instead of LiveData. All LiveData occurrences in current code +should be replaced with flows in future. + +Why is this important? + +1. Deep Kotlin integration +------ +Flow is tightly integrated with Kotlin Coroutines. Code parts which are using Kotlin Coroutines is +usual use flows, we don't need to map data to another format and could use it as is. + +2. Decreasing dependencies count +------ +If we are using language based choose, we don't need to add additional dependencies to our code. + +Project Impact +------ + +This decision will impact the project in the following ways: +------ +Improved performance and functionality. +Reducing application size. + +Implementation +------ +To implement this decision, we will use flows as asynchronous data streams for future development. +We will replace LiveData with flows during refactoring + +Alternatives +------ +Continuing to use LiveData as asynchronous data streams, will keep job to us to maintain LiveData +library updates. \ No newline at end of file diff --git a/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt b/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt index cfe14bdf4..1b9bb6750 100644 --- a/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt +++ b/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt @@ -42,7 +42,7 @@ interface ProfileApi { suspend fun deleteProfileImage(@Path("username") username: String?): Response @FormUrlEncoded - @POST("/mobile_api_extensions/user/v1/accounts/deactivate_logout/") + @POST("/api/user/v1/accounts/deactivate_logout/") suspend fun deactivateAccount( @Field("password") password: String ): Response diff --git a/profile/src/main/java/org/openedx/profile/data/model/Account.kt b/profile/src/main/java/org/openedx/profile/data/model/Account.kt index c3fe9304e..ff069376a 100644 --- a/profile/src/main/java/org/openedx/profile/data/model/Account.kt +++ b/profile/src/main/java/org/openedx/profile/data/model/Account.kt @@ -60,7 +60,9 @@ data class Account( yearOfBirth = yearOfBirth, levelOfEducation = levelOfEducation ?: "", goals = goals ?: "", - languageProficiencies = languageProficiencies!!.map { it.mapToDomain() }, + languageProficiencies = languageProficiencies?.let { languageProficiencyList -> + languageProficiencyList.map { it.mapToDomain() } + } ?: emptyList(), gender = gender ?: "", mailingAddress = mailingAddress ?: "", email = email, diff --git a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt index 7e64c98cc..ce5580a45 100644 --- a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt +++ b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt @@ -1,31 +1,46 @@ package org.openedx.profile.data.repository import androidx.room.RoomDatabase -import org.openedx.core.ApiConstants -import org.openedx.profile.data.api.ProfileApi import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.asRequestBody +import org.openedx.core.ApiConstants +import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.profile.data.api.ProfileApi +import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.profile.domain.model.Account import java.io.File class ProfileRepository( + private val config: Config, private val api: ProfileApi, private val room: RoomDatabase, - private val preferencesManager: CorePreferences + private val profilePreferences: ProfilePreferences, + private val corePreferences: CorePreferences, ) { suspend fun getAccount(): Account { - return api.getAccount(preferencesManager.user?.username!!).mapToDomain() + val account = api.getAccount(corePreferences.user?.username!!) + profilePreferences.profile = account + return account.mapToDomain() + } + + suspend fun getAccount(username: String): Account { + val account = api.getAccount(username) + return account.mapToDomain() + } + + fun getCachedAccount(): Account? { + return profilePreferences.profile?.mapToDomain() } suspend fun updateAccount(fields: Map): Account { - return api.updateAccount(preferencesManager.user?.username!!, fields).mapToDomain() + return api.updateAccount(corePreferences.user?.username!!, fields).mapToDomain() } suspend fun setProfileImage(file: File, mimeType: String) { api.setProfileImage( - preferencesManager.user?.username!!, + corePreferences.user?.username!!, "attachment;filename=filename.${file.extension}", true, file.asRequestBody(mimeType.toMediaType()) @@ -33,18 +48,21 @@ class ProfileRepository( } suspend fun deleteProfileImage() { - api.deleteProfileImage(preferencesManager.user?.username!!) + api.deleteProfileImage(corePreferences.user?.username!!) } suspend fun deactivateAccount(password: String) = api.deactivateAccount(password) suspend fun logout() { - api.revokeAccessToken( - org.openedx.core.BuildConfig.CLIENT_ID, - preferencesManager.refreshToken, - ApiConstants.TOKEN_TYPE_REFRESH - ) - preferencesManager.clear() - room.clearAllTables() + try { + api.revokeAccessToken( + config.getOAuthClientId(), + corePreferences.refreshToken, + ApiConstants.TOKEN_TYPE_REFRESH + ) + } finally { + corePreferences.clear() + room.clearAllTables() + } } -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/data/storage/ProfilePreferences.kt b/profile/src/main/java/org/openedx/profile/data/storage/ProfilePreferences.kt index 857c1d769..aba477e0a 100644 --- a/profile/src/main/java/org/openedx/profile/data/storage/ProfilePreferences.kt +++ b/profile/src/main/java/org/openedx/profile/data/storage/ProfilePreferences.kt @@ -1,6 +1,6 @@ package org.openedx.profile.data.storage -import org.openedx.profile.domain.model.Account +import org.openedx.profile.data.model.Account interface ProfilePreferences { var profile: Account? diff --git a/profile/src/main/java/org/openedx/profile/domain/interactor/ProfileInteractor.kt b/profile/src/main/java/org/openedx/profile/domain/interactor/ProfileInteractor.kt index 1364c7370..cbad3b4fe 100644 --- a/profile/src/main/java/org/openedx/profile/domain/interactor/ProfileInteractor.kt +++ b/profile/src/main/java/org/openedx/profile/domain/interactor/ProfileInteractor.kt @@ -7,6 +7,10 @@ class ProfileInteractor(private val repository: ProfileRepository) { suspend fun getAccount() = repository.getAccount() + suspend fun getAccount(username: String) = repository.getAccount(username) + + fun getCachedAccount() = repository.getCachedAccount() + suspend fun updateAccount(fields: Map) = repository.updateAccount(fields) suspend fun setProfileImage(file: File, mimeType: String) = repository.setProfileImage(file, mimeType) diff --git a/profile/src/main/java/org/openedx/profile/domain/model/Account.kt b/profile/src/main/java/org/openedx/profile/domain/model/Account.kt index a195f6dbe..f338fc452 100644 --- a/profile/src/main/java/org/openedx/profile/domain/model/Account.kt +++ b/profile/src/main/java/org/openedx/profile/domain/model/Account.kt @@ -1,53 +1,35 @@ package org.openedx.profile.domain.model import android.os.Parcelable -import com.google.gson.annotations.SerializedName -import org.openedx.core.AppDataConstants.USER_MIN_YEAR import kotlinx.parcelize.Parcelize +import org.openedx.core.AppDataConstants.USER_MIN_YEAR import org.openedx.core.domain.model.LanguageProficiency import org.openedx.core.domain.model.ProfileImage -import java.util.* +import java.util.Calendar +import java.util.Date @Parcelize data class Account( - @SerializedName("username") val username: String, - @SerializedName("bio") val bio: String, - @SerializedName("requires_parental_consent") val requiresParentalConsent: Boolean, - @SerializedName("name") val name: String, - @SerializedName("country") val country: String, - @SerializedName("is_active") val isActive: Boolean, - @SerializedName("profile_image") val profileImage: ProfileImage, - @SerializedName("year_of_birth") val yearOfBirth: Int?, - @SerializedName("level_of_education") val levelOfEducation: String, - @SerializedName("goals") val goals: String, - @SerializedName("language_proficiencies") val languageProficiencies: List, - @SerializedName("gender") val gender: String, - @SerializedName("mailing_address") val mailingAddress: String, - @SerializedName("email") val email: String?, - @SerializedName("date_joined") val dateJoined: Date?, - @SerializedName("account_privacy") val accountPrivacy: Privacy ) : Parcelable { enum class Privacy { - @SerializedName("private") PRIVATE, - @SerializedName("all_users") ALL_USERS } diff --git a/profile/src/main/java/org/openedx/profile/domain/model/Configuration.kt b/profile/src/main/java/org/openedx/profile/domain/model/Configuration.kt new file mode 100644 index 000000000..f35d18ac4 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/domain/model/Configuration.kt @@ -0,0 +1,16 @@ +package org.openedx.profile.domain.model + +import org.openedx.core.domain.model.AgreementUrls + +/** + * @param agreementUrls User agreement urls + * @param faqUrl FAQ url + * @param supportEmail Email address of support + * @param versionName Version of the application (1.0.0) + */ +data class Configuration( + val agreementUrls: AgreementUrls, + val faqUrl: String, + val supportEmail: String, + val versionName: String, +) diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt index 27990b563..66d43b607 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt @@ -6,7 +6,10 @@ interface ProfileAnalytics { fun profileDeleteAccountClickedEvent() fun profileVideoSettingsClickedEvent() fun privacyPolicyClickedEvent() + fun termsOfUseClickedEvent() fun cookiePolicyClickedEvent() + fun dataSellClickedEvent() + fun faqClickedEvent() fun emailSupportClickedEvent() fun logoutEvent(force: Boolean) -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt index 457b02035..2bf343284 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt @@ -13,6 +13,7 @@ interface ProfileRouter { fun navigateToDeleteAccount(fm: FragmentManager) - fun restartApp(fm: FragmentManager) + fun navigateToWebContent(fm: FragmentManager, title: String, url: String) -} \ No newline at end of file + fun restartApp(fm: FragmentManager, isLogistrationEnabled: Boolean) +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileFragment.kt new file mode 100644 index 000000000..c311f88da --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileFragment.kt @@ -0,0 +1,257 @@ +package org.openedx.profile.presentation.anothers_account + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +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.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.ProfileImage +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.profile.domain.model.Account +import org.openedx.profile.presentation.ui.ProfileInfoSection +import org.openedx.profile.presentation.ui.ProfileTopic + +class AnothersProfileFragment : Fragment() { + + private val viewModel: AnothersProfileViewModel by viewModel { + parametersOf(requireArguments().getString(ARG_USERNAME, "")) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + + val uiState by viewModel.uiState + val uiMessage by viewModel.uiMessage + + AnothersProfileScreen( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }, + ) + } + } + } + + companion object { + private const val ARG_USERNAME = "username" + fun newInstance( + username: String, + ): AnothersProfileFragment { + val fragment = AnothersProfileFragment() + fragment.arguments = bundleOf( + ARG_USERNAME to username + ) + return fragment + } + } +} + +@Composable +private fun AnothersProfileScreen( + windowSize: WindowSize, + uiState: AnothersProfileUIState, + uiMessage: UIMessage?, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + topBar = { + Column( + Modifier + .fillMaxWidth() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .then(topBarWidth), + contentAlignment = Alignment.CenterStart + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.core_profile), + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.titleMedium + ) + BackBtn { + onBackClick() + } + } + } + } + ) { paddingValues -> + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .background(MaterialTheme.appColors.background), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (uiState) { + is AnothersProfileUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is AnothersProfileUIState.Data -> { + Column( + Modifier + .fillMaxHeight() + .then(contentWidth) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ProfileTopic(uiState.account) + + Spacer(modifier = Modifier.height(36.dp)) + + ProfileInfoSection(uiState.account) + + Spacer(modifier = Modifier.height(36.dp)) + } + } + } + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ProfileScreenPreview() { + OpenEdXTheme { + AnothersProfileScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = AnothersProfileUIState.Data(mockAccount), + uiMessage = null, + onBackClick = {} + ) + } +} + + +@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ProfileScreenTabletPreview() { + OpenEdXTheme { + AnothersProfileScreen( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = AnothersProfileUIState.Data(mockAccount), + uiMessage = null, + onBackClick = {} + ) + } +} + +private val mockAccount = Account( + username = "thom84", + bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", + requiresParentalConsent = true, + name = "Thomas", + country = "Ukraine", + isActive = true, + profileImage = ProfileImage("", "", "", "", false), + yearOfBirth = 2000, + levelOfEducation = "Bachelor", + goals = "130", + languageProficiencies = emptyList(), + gender = "male", + mailingAddress = "", + "", + null, + accountPrivacy = Account.Privacy.ALL_USERS +) diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileUIState.kt new file mode 100644 index 000000000..ebffce6dd --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileUIState.kt @@ -0,0 +1,8 @@ +package org.openedx.profile.presentation.anothers_account + +import org.openedx.profile.domain.model.Account + +sealed class AnothersProfileUIState { + data class Data(val account: Account) : AnothersProfileUIState() + object Loading : AnothersProfileUIState() +} \ No newline at end of file diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileViewModel.kt new file mode 100644 index 000000000..b0c82e3e0 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileViewModel.kt @@ -0,0 +1,49 @@ +package org.openedx.profile.presentation.anothers_account + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.extension.isInternetError +import org.openedx.core.system.ResourceManager +import org.openedx.profile.domain.interactor.ProfileInteractor + +class AnothersProfileViewModel( + private val interactor: ProfileInteractor, + private val resourceManager: ResourceManager, + val username: String +) : BaseViewModel() { + + private val _uiState = mutableStateOf(AnothersProfileUIState.Loading) + val uiState: State + get() = _uiState + + private val _uiMessage = mutableStateOf(null) + val uiMessage: State + get() = _uiMessage + + init { + getAccount(username) + } + + private fun getAccount(username: String) { + _uiState.value = AnothersProfileUIState.Loading + viewModelScope.launch { + try { + val account = interactor.getAccount(username) + _uiState.value = AnothersProfileUIState.Data(account) + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + } else { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + } + } + } + } +} \ No newline at end of file diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt index f4ea03806..701fa4bb0 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt @@ -5,16 +5,31 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.rememberScaffoldState -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView @@ -33,17 +48,27 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.R import org.openedx.core.UIMessage -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedTextField +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.edit.EditProfileFragment import org.openedx.profile.presentation.profile.ProfileViewModel -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.profile.R as profileR class DeleteProfileFragment : Fragment() { @@ -91,7 +116,10 @@ class DeleteProfileFragment : Fragment() { LaunchedEffect(logoutSuccess) { if (logoutSuccess) { - router.restartApp(requireActivity().supportFragmentManager) + router.restartApp( + requireActivity().supportFragmentManager, + logoutViewModel.isLogistrationEnabled + ) } } } @@ -159,7 +187,8 @@ fun DeleteProfileScreen( Column( modifier = Modifier .padding(paddingValues) - .statusBarsInset(), + .statusBarsInset() + .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { Box( diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt index 43515f98a..e911d1b4b 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt @@ -41,6 +41,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon @@ -77,6 +78,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -107,12 +109,12 @@ import org.koin.core.parameter.parametersOf import org.openedx.core.AppDataConstants.DEFAULT_MIME_TYPE import org.openedx.core.R import org.openedx.core.UIMessage -import org.openedx.profile.domain.model.Account import org.openedx.core.domain.model.LanguageProficiency import org.openedx.core.domain.model.ProfileImage import org.openedx.core.domain.model.RegistrationField import org.openedx.core.extension.getFileName import org.openedx.core.extension.parcelable +import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.IconText @@ -121,6 +123,7 @@ import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.SheetContent import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.isImeVisibleState import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.rememberSaveableMap @@ -132,6 +135,7 @@ import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.LocaleUtils +import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileRouter import java.io.ByteArrayOutputStream import java.io.File @@ -520,21 +524,34 @@ private fun EditProfileScreen( } if (leaveDialog) { - LeaveProfile( - onDismissRequest = { - onKeepEdit() - }, - onLeaveClick = { - onBackClick(false) - } - ) + val configuration = LocalConfiguration.current + if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT || windowSize.isTablet) { + LeaveProfile( + onDismissRequest = { + onKeepEdit() + }, + onLeaveClick = { + onBackClick(false) + } + ) + } else { + LeaveProfileLandscape( + onDismissRequest = { + onKeepEdit() + }, + onLeaveClick = { + onBackClick(false) + } + ) + } } Column( modifier = Modifier .fillMaxWidth() .padding(paddingValues) - .statusBarsInset(), + .statusBarsInset() + .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { Box( @@ -605,7 +622,7 @@ private fun EditProfileScreen( .placeholder(R.drawable.core_ic_default_profile_picture) .build(), contentScale = ContentScale.Crop, - contentDescription = null, + contentDescription = stringResource(id = R.string.core_accessibility_user_profile_image, uiState.account.username), modifier = Modifier .border( 2.dp, @@ -1035,12 +1052,14 @@ private fun LeaveProfile( onDismissRequest: () -> Unit, onLeaveClick: () -> Unit, ) { + val scrollState = rememberScrollState() Dialog( onDismissRequest = onDismissRequest, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), content = { Column( Modifier + .verticalScroll(scrollState) .fillMaxWidth() .background( MaterialTheme.appColors.background, @@ -1102,6 +1121,112 @@ private fun LeaveProfile( }) } +@Composable +private fun LeaveProfileLandscape( + onDismissRequest: () -> Unit, + onLeaveClick: () -> Unit, +) { + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false, usePlatformDefaultWidth = false), + content = { + Card( + modifier = Modifier + .width(screenWidth * 0.7f) + .clip(MaterialTheme.appShapes.courseImageShape), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape + ) { + Row( + Modifier + .padding(horizontal = 40.dp) + .padding(top = 48.dp, bottom = 38.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(100.dp), + painter = painterResource(id = org.openedx.profile.R.drawable.profile_ic_save), + contentDescription = null, + tint = MaterialTheme.appColors.onBackground + ) + Spacer(Modifier.height(20.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = org.openedx.profile.R.string.profile_leave_profile), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = org.openedx.profile.R.string.profile_changes_you_made), + color = MaterialTheme.appColors.textFieldText, + style = MaterialTheme.appTypography.titleSmall, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.width(42.dp)) + Column( + Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OpenEdXButton( + text = stringResource(id = org.openedx.profile.R.string.profile_leave), + backgroundColor = MaterialTheme.appColors.warning, + content = { + AutoSizeText( + text = stringResource(id = org.openedx.profile.R.string.profile_leave), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + }, + onClick = onLeaveClick + ) + Spacer(Modifier.height(16.dp)) + OpenEdXOutlinedButton( + borderColor = MaterialTheme.appColors.textPrimary, + textColor = MaterialTheme.appColors.textPrimary, + text = stringResource(id = org.openedx.profile.R.string.profile_keep_editing), + onClick = onDismissRequest, + content = { + AutoSizeText( + text = stringResource(id = org.openedx.profile.R.string.profile_keep_editing), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textPrimary + ) + } + ) + } + } + } + }) +} + +@Preview +@Composable +fun LeaveProfilePreview() { + LeaveProfile( + onDismissRequest = {}, + onLeaveClick = {} + ) +} + +@Preview +@Composable +fun LeaveProfileLandscapePreview() { + LeaveProfileLandscape( + onDismissRequest = {}, + onLeaveClick = {} + ) +} @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = UI_MODE_NIGHT_YES) diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt index 96ee13059..4c18cd103 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt @@ -1,64 +1,24 @@ package org.openedx.profile.presentation.profile -import android.content.res.Configuration.UI_MODE_NIGHT_NO -import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForwardIos -import androidx.compose.material.icons.filled.ExitToApp -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.fragment.app.Fragment -import coil.compose.AsyncImage -import coil.request.ImageRequest -import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.profile.domain.model.Account -import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.presentation.global.AppData -import org.openedx.core.presentation.global.AppDataHolder -import org.openedx.core.ui.* +import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appShapes -import org.openedx.core.ui.theme.appTypography -import org.openedx.core.utils.EmailUtil -import org.openedx.profile.presentation.ProfileRouter +import org.openedx.profile.presentation.profile.compose.ProfileView +import org.openedx.profile.presentation.profile.compose.ProfileViewAction class ProfileFragment : Fragment() { private val viewModel: ProfileViewModel by viewModel() - private val router by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -74,603 +34,83 @@ class ProfileFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() - - val uiState by viewModel.uiState.observeAsState() + val uiState by viewModel.uiState.collectAsState() val logoutSuccess by viewModel.successLogout.observeAsState(false) val uiMessage by viewModel.uiMessage.observeAsState() val refreshing by viewModel.isUpdating.observeAsState(false) + val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null) - ProfileScreen( + ProfileView( windowSize = windowSize, - uiState = uiState!!, + uiState = uiState, uiMessage = uiMessage, - appData = (requireActivity() as AppDataHolder).appData, refreshing = refreshing, - logout = { - viewModel.logout() - }, - editAccountClicked = { - viewModel.profileEditClickedEvent() - router.navigateToEditProfile( - requireParentFragment().parentFragmentManager, - it - ) - }, - onSwipeRefresh = { - viewModel.updateAccount() - }, - onVideoSettingsClick = { - viewModel.profileVideoSettingsClickedEvent() - router.navigateToVideoSettings( - requireParentFragment().parentFragmentManager - ) - }, - onSupportClick = { action -> + appUpgradeEvent = appUpgradeEvent, + onAction = { action -> when (action) { - SupportClickAction.SUPPORT -> viewModel.emailSupportClickedEvent() - SupportClickAction.COOKIE_POLICY -> viewModel.cookiePolicyClickedEvent() - SupportClickAction.PRIVACY_POLICY -> viewModel.privacyPolicyClickedEvent() - } - } - ) - - LaunchedEffect(logoutSuccess) { - if (logoutSuccess) { - router.restartApp(requireParentFragment().parentFragmentManager) - } - } - } - } - } -} - -private enum class SupportClickAction { - SUPPORT, PRIVACY_POLICY, COOKIE_POLICY -} - - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun ProfileScreen( - windowSize: WindowSize, - uiState: ProfileUIState, - appData: AppData, - uiMessage: UIMessage?, - refreshing: Boolean, - onVideoSettingsClick: () -> Unit, - logout: () -> Unit, - onSwipeRefresh: () -> Unit, - onSupportClick: (SupportClickAction) -> Unit, - editAccountClicked: (Account) -> Unit -) { - val scaffoldState = rememberScaffoldState() - var showLogoutDialog by rememberSaveable { mutableStateOf(false) } - - val pullRefreshState = - rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) - - Scaffold( - modifier = Modifier.fillMaxSize(), - scaffoldState = scaffoldState - ) { paddingValues -> - - val contentWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), - compact = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - ) - ) - } - - val topBarWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), - compact = Modifier - .fillMaxWidth() - ) - ) - } - - - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - - if (showLogoutDialog) { - LogoutDialog( - onDismissRequest = { - showLogoutDialog = false - }, - onLogoutClick = { - showLogoutDialog = false - logout() - } - ) - } - - Column( - modifier = Modifier - .padding(paddingValues) - .statusBarsInset(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = topBarWidth, - contentAlignment = Alignment.CenterEnd - ) { - Text( - modifier = Modifier - .fillMaxWidth(), - text = stringResource(id = R.string.core_profile), - color = MaterialTheme.appColors.textPrimary, - textAlign = TextAlign.Center, - style = MaterialTheme.appTypography.titleMedium - ) - - IconText( - modifier = Modifier - .height(48.dp) - .padding(end = 24.dp), - text = stringResource(org.openedx.profile.R.string.profile_edit), - painter = painterResource(id = R.drawable.core_ic_edit), - textStyle = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.primary, - onClick = { - if (uiState is ProfileUIState.Data) { - editAccountClicked(uiState.account) - } - } - ) - } - Surface( - color = MaterialTheme.appColors.background - ) { - Box( - modifier = Modifier.pullRefresh(pullRefreshState), - contentAlignment = Alignment.TopCenter - ) { - Column( - modifier = Modifier - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - when (uiState) { - is ProfileUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } + ProfileViewAction.AppVersionClick -> { + viewModel.appVersionClickedEvent(requireContext()) } - is ProfileUIState.Data -> { - Column( - Modifier - .fillMaxHeight() - .then(contentWidth) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally - ) { - val profileImage = if (uiState.account.profileImage.hasImage) { - uiState.account.profileImage.imageUrlFull - } else { - R.drawable.core_ic_default_profile_picture - } - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(profileImage) - .error(R.drawable.core_ic_default_profile_picture) - .placeholder(R.drawable.core_ic_default_profile_picture) - .build(), - contentDescription = null, - modifier = Modifier - .border( - 2.dp, - MaterialTheme.appColors.onSurface, - CircleShape - ) - .padding(2.dp) - .size(100.dp) - .clip(CircleShape) - ) - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = uiState.account.name, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.headlineSmall - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "@${uiState.account.username}", - color = MaterialTheme.appColors.textPrimaryVariant, - style = MaterialTheme.appTypography.labelLarge - ) - Spacer(modifier = Modifier.height(36.dp)) - - Column( - Modifier - .fillMaxWidth() - ) { - ProfileInfoSection(uiState.account) - - Spacer(modifier = Modifier.height(24.dp)) - - SettingsSection(onVideoSettingsClick = { - onVideoSettingsClick() - }) + ProfileViewAction.EditAccountClick -> { + viewModel.profileEditClicked( + requireParentFragment().parentFragmentManager + ) + } - Spacer(modifier = Modifier.height(24.dp)) + ProfileViewAction.LogoutClick -> { + viewModel.logout() + } - SupportInfoSection(appData, onClick = onSupportClick) + ProfileViewAction.PrivacyPolicyClick -> { + viewModel.privacyPolicyClicked( + requireParentFragment().parentFragmentManager + ) + } - Spacer(modifier = Modifier.height(24.dp)) + ProfileViewAction.CookiePolicyClick -> { + viewModel.cookiePolicyClicked( + requireParentFragment().parentFragmentManager + ) + } - LogoutButton( - onClick = { showLogoutDialog = true } - ) + ProfileViewAction.DataSellClick -> { + viewModel.dataSellClicked( + requireParentFragment().parentFragmentManager + ) + } - Spacer(Modifier.height(30.dp)) - } + ProfileViewAction.FaqClick -> viewModel.faqClicked() - } + ProfileViewAction.SupportClick -> { + viewModel.emailSupportClicked(requireContext()) } - } - } - PullRefreshIndicator( - refreshing, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - } - } - } - } -} -@Composable -private fun ProfileInfoSection(account: Account) { - - if (account.yearOfBirth != null || account.bio.isNotEmpty()) { - Column { - Text( - text = stringResource(id = org.openedx.profile.R.string.profile_prof_info), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textSecondary - ) - Spacer(modifier = Modifier.height(14.dp)) - Card( - modifier = Modifier, - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - backgroundColor = MaterialTheme.appColors.cardViewBackground - ) { - Column( - Modifier - .fillMaxWidth() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - if (account.yearOfBirth != null) { - Text( - text = buildAnnotatedString { - val value = if (account.yearOfBirth != null) { - account.yearOfBirth.toString() - } else "" - val text = stringResource( - id = org.openedx.profile.R.string.profile_year_of_birth, - value - ) - append(text) - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimaryVariant - ), - start = 0, - end = text.length - value.length - ) - }, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - } - if (account.bio.isNotEmpty()) { - Text( - text = buildAnnotatedString { - val text = stringResource( - id = org.openedx.profile.R.string.profile_bio, - account.bio - ) - append(text) - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimaryVariant - ), - start = 0, - end = text.length - account.bio.length + ProfileViewAction.TermsClick -> { + viewModel.termsOfUseClicked( + requireParentFragment().parentFragmentManager ) - }, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - } - } - } - } - } -} + } -@Composable -fun SettingsSection(onVideoSettingsClick: () -> Unit) { - Column { - Text( - text = stringResource(id = org.openedx.profile.R.string.profile_settings), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textSecondary - ) - Spacer(modifier = Modifier.height(14.dp)) - Card( - modifier = Modifier, - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - backgroundColor = MaterialTheme.appColors.cardViewBackground - ) { - Column( - Modifier - .fillMaxWidth() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - ProfileInfoItem( - text = stringResource(id = org.openedx.profile.R.string.profile_video_settings), - onClick = onVideoSettingsClick - ) - } - } - } -} + ProfileViewAction.VideoSettingsClick -> { + viewModel.profileVideoSettingsClicked( + requireParentFragment().parentFragmentManager + ) + } -@Composable -private fun SupportInfoSection( - appData: AppData, - onClick: (SupportClickAction) -> Unit -) { - val uriHandler = LocalUriHandler.current - val context = LocalContext.current - Column { - Text( - text = stringResource(id = org.openedx.profile.R.string.profile_support_info), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textSecondary - ) - Spacer(modifier = Modifier.height(14.dp)) - Card( - modifier = Modifier, - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - backgroundColor = MaterialTheme.appColors.cardViewBackground - ) { - Column( - Modifier - .fillMaxWidth() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - ProfileInfoItem( - text = stringResource(id = org.openedx.profile.R.string.profile_contact_support), - onClick = { - onClick(SupportClickAction.SUPPORT) - EmailUtil.showFeedbackScreen( - context, - context.getString(R.string.core_email_subject), - appData.versionName - ) - } - ) - Divider(color = MaterialTheme.appColors.divider) - ProfileInfoItem( - text = stringResource(id = R.string.core_terms_of_use), - onClick = { - onClick(SupportClickAction.COOKIE_POLICY) - uriHandler.openUri(context.getString(R.string.terms_of_service_link)) - } - ) - Divider(color = MaterialTheme.appColors.divider) - ProfileInfoItem( - text = stringResource(id = R.string.core_privacy_policy), - onClick = { - onClick(SupportClickAction.PRIVACY_POLICY) - uriHandler.openUri(context.getString(R.string.privacy_policy_link)) - } + ProfileViewAction.SwipeRefresh -> { + viewModel.updateAccount() + } + } + }, ) - } - } - } -} -@Composable -private fun LogoutButton(onClick: () -> Unit) { - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { - onClick() - }, - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - backgroundColor = MaterialTheme.appColors.cardViewBackground - ) { - Row( - modifier = Modifier.padding(20.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(id = org.openedx.profile.R.string.profile_logout), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.error - ) - Icon( - imageVector = Icons.Filled.ExitToApp, - contentDescription = null, - tint = MaterialTheme.appColors.error - ) - } - } -} - -@Composable -private fun LogoutDialog( - onDismissRequest: () -> Unit, - onLogoutClick: () -> Unit, -) { - Dialog( - onDismissRequest = onDismissRequest, - content = { - Column( - Modifier - .fillMaxWidth() - .background( - MaterialTheme.appColors.background, - MaterialTheme.appShapes.cardShape - ) - .clip(MaterialTheme.appShapes.cardShape) - .border( - 1.dp, - MaterialTheme.appColors.cardViewBorder, - MaterialTheme.appShapes.cardShape - ) - .padding(horizontal = 40.dp) - .padding(top = 48.dp, bottom = 36.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - modifier = Modifier - .width(88.dp) - .height(85.dp), - painter = painterResource(org.openedx.profile.R.drawable.profile_ic_exit), - contentDescription = null, - tint = MaterialTheme.appColors.onBackground - ) - Spacer(Modifier.size(40.dp)) - Text( - text = stringResource(id = org.openedx.profile.R.string.profile_logout_dialog_body), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleLarge, - textAlign = TextAlign.Center - ) - Spacer(Modifier.size(44.dp)) - OpenEdXButton( - text = stringResource(id = org.openedx.profile.R.string.profile_logout), - backgroundColor = MaterialTheme.appColors.warning, - onClick = onLogoutClick, - content = { - Box( - Modifier - .fillMaxWidth(), - contentAlignment = Alignment.CenterEnd - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = org.openedx.profile.R.string.profile_logout), - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.labelLarge, - textAlign = TextAlign.Center - ) - Icon( - painter = painterResource(id = org.openedx.profile.R.drawable.profile_ic_logout), - contentDescription = null, - tint = Color.Black - ) - } + LaunchedEffect(logoutSuccess) { + if (logoutSuccess) { + viewModel.restartApp(requireParentFragment().parentFragmentManager) } - ) + } } } - ) -} - -@Composable -private fun ProfileInfoItem(text: String, onClick: () -> Unit) { - Row( - Modifier - .fillMaxWidth() - .clickable { onClick() }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = text, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - Icon( - modifier = Modifier.size(16.dp), - imageVector = Icons.Filled.ArrowForwardIos, - contentDescription = null - ) - } -} - -@Preview(uiMode = UI_MODE_NIGHT_NO) -@Preview(uiMode = UI_MODE_NIGHT_YES) -@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_NO) -@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun ProfileScreenPreview() { - OpenEdXTheme { - ProfileScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = ProfileUIState.Data(mockAccount), - uiMessage = null, - refreshing = false, - logout = {}, - onSwipeRefresh = {}, - editAccountClicked = {}, - onVideoSettingsClick = {}, - onSupportClick = {}, - appData = AppData("1") - ) } } - - -@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_NO) -@Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun ProfileScreenTabletPreview() { - OpenEdXTheme { - ProfileScreen( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = ProfileUIState.Data(mockAccount), - uiMessage = null, - refreshing = false, - logout = {}, - onSwipeRefresh = {}, - editAccountClicked = {}, - onVideoSettingsClick = {}, - onSupportClick = {}, - appData = AppData("1") - ) - } -} - -private val mockAccount = Account( - username = "thom84", - bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", - requiresParentalConsent = true, - name = "Thomas", - country = "Ukraine", - isActive = true, - profileImage = ProfileImage("", "", "", "", false), - yearOfBirth = 2000, - levelOfEducation = "Bachelor", - goals = "130", - languageProficiencies = emptyList(), - gender = "male", - mailingAddress = "", - "", - null, - accountPrivacy = Account.Privacy.ALL_USERS -) diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileUIState.kt index c33975a5f..f869b7f0b 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileUIState.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileUIState.kt @@ -1,8 +1,17 @@ package org.openedx.profile.presentation.profile import org.openedx.profile.domain.model.Account +import org.openedx.profile.domain.model.Configuration sealed class ProfileUIState { - data class Data(val account: Account) : ProfileUIState() + /** + * @param account User account data + * @param configuration Configuration data + */ + data class Data( + val account: Account, + val configuration: Configuration, + ) : ProfileUIState() + object Loading : ProfileUIState() -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt index 081b041f8..0b0eafd8a 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt @@ -1,40 +1,56 @@ package org.openedx.profile.presentation.profile +import android.content.Context +import androidx.compose.ui.text.intl.Locale +import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.openedx.core.AppUpdateState import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.UIMessage +import org.openedx.core.config.Config import org.openedx.core.extension.isInternetError import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.presentation.global.AppData import org.openedx.core.system.AppCookieManager import org.openedx.core.system.ResourceManager +import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.utils.EmailUtil import org.openedx.profile.domain.interactor.ProfileInteractor +import org.openedx.profile.domain.model.Configuration import org.openedx.profile.presentation.ProfileAnalytics +import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.system.notifier.AccountDeactivated import org.openedx.profile.system.notifier.AccountUpdated import org.openedx.profile.system.notifier.ProfileNotifier -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.openedx.profile.data.storage.ProfilePreferences class ProfileViewModel( + private val appData: AppData, + private val config: Config, private val interactor: ProfileInteractor, - private val preferencesManager: ProfilePreferences, private val resourceManager: ResourceManager, private val notifier: ProfileNotifier, private val dispatcher: CoroutineDispatcher, private val cookieManager: AppCookieManager, private val workerController: DownloadWorkerController, - private val analytics: ProfileAnalytics + private val analytics: ProfileAnalytics, + private val router: ProfileRouter, + private val appUpgradeNotifier: AppUpgradeNotifier ) : BaseViewModel() { - private val _uiState = MutableLiveData(ProfileUIState.Loading) - val uiState: LiveData - get() = _uiState + private val _uiState: MutableStateFlow = + MutableStateFlow(ProfileUIState.Loading) + internal val uiState: StateFlow = _uiState.asStateFlow() private val _successLogout = MutableLiveData() val successLogout: LiveData @@ -48,8 +64,23 @@ class ProfileViewModel( val isUpdating: LiveData get() = _isUpdating + private val _appUpgradeEvent = MutableLiveData() + val appUpgradeEvent: LiveData + get() = _appUpgradeEvent + + val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() + + private val configuration + get() = Configuration( + agreementUrls = config.getAgreement(Locale.current.language), + faqUrl = config.getFaqUrl(), + supportEmail = config.getFeedbackEmailAddress(), + versionName = appData.versionName, + ) + init { getAccount() + collectAppUpgradeEvent() } override fun onCreate(owner: LifecycleOwner) { @@ -69,15 +100,20 @@ class ProfileViewModel( _uiState.value = ProfileUIState.Loading viewModelScope.launch { try { - val cachedAccount = preferencesManager.profile + val cachedAccount = interactor.getCachedAccount() if (cachedAccount == null) { _uiState.value = ProfileUIState.Loading } else { - _uiState.value = ProfileUIState.Data(cachedAccount) + _uiState.value = ProfileUIState.Data( + account = cachedAccount, + configuration = configuration, + ) } val account = interactor.getAccount() - _uiState.value = ProfileUIState.Data(account) - preferencesManager.profile = account + _uiState.value = ProfileUIState.Data( + account = account, + configuration = configuration, + ) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = @@ -104,9 +140,6 @@ class ProfileViewModel( withContext(dispatcher) { interactor.logout() } - cookieManager.clearWebViewCookie() - analytics.logoutEvent(false) - _successLogout.value = true } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = @@ -115,28 +148,95 @@ class ProfileViewModel( _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) } + } finally { + cookieManager.clearWebViewCookie() + analytics.logoutEvent(false) + _successLogout.value = true } } } - fun profileEditClickedEvent() { + private fun collectAppUpgradeEvent() { + viewModelScope.launch { + appUpgradeNotifier.notifier.collect { event -> + _appUpgradeEvent.value = event + } + } + } + + fun profileEditClicked(fragmentManager: FragmentManager) { + (uiState.value as? ProfileUIState.Data)?.let { data -> + router.navigateToEditProfile( + fragmentManager, + data.account + ) + } analytics.profileEditClickedEvent() } - fun profileVideoSettingsClickedEvent() { + fun profileVideoSettingsClicked(fragmentManager: FragmentManager) { + router.navigateToVideoSettings(fragmentManager) analytics.profileVideoSettingsClickedEvent() } - fun privacyPolicyClickedEvent() { + fun privacyPolicyClicked(fragmentManager: FragmentManager) { + router.navigateToWebContent( + fm = fragmentManager, + title = resourceManager.getString(R.string.core_privacy_policy), + url = configuration.agreementUrls.privacyPolicyUrl, + ) analytics.privacyPolicyClickedEvent() } - fun cookiePolicyClickedEvent() { + fun cookiePolicyClicked(fragmentManager: FragmentManager) { + router.navigateToWebContent( + fm = fragmentManager, + title = resourceManager.getString(R.string.core_cookie_policy), + url = configuration.agreementUrls.cookiePolicyUrl, + ) analytics.cookiePolicyClickedEvent() } - fun emailSupportClickedEvent() { + fun dataSellClicked(fragmentManager: FragmentManager) { + router.navigateToWebContent( + fm = fragmentManager, + title = resourceManager.getString(R.string.core_data_sell), + url = configuration.agreementUrls.dataSellConsentUrl, + ) + analytics.dataSellClickedEvent() + } + + fun faqClicked() { + analytics.faqClickedEvent() + } + + fun termsOfUseClicked(fragmentManager: FragmentManager) { + router.navigateToWebContent( + fm = fragmentManager, + title = resourceManager.getString(R.string.core_terms_of_use), + url = configuration.agreementUrls.tosUrl, + ) + analytics.termsOfUseClickedEvent() + } + + fun emailSupportClicked(context: Context) { + EmailUtil.showFeedbackScreen( + context = context, + feedbackEmailAddress = config.getFeedbackEmailAddress(), + appVersion = appData.versionName + ) analytics.emailSupportClickedEvent() } -} \ No newline at end of file + fun appVersionClickedEvent(context: Context) { + AppUpdateState.openPlayMarket(context) + } + + fun restartApp(fragmentManager: FragmentManager) { + router.restartApp( + fragmentManager, + isLogistrationEnabled + ) + } + +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt new file mode 100644 index 000000000..cd544a7a7 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt @@ -0,0 +1,793 @@ +package org.openedx.profile.presentation.profile.compose + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExitToApp +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.AgreementUrls +import org.openedx.core.domain.model.ProfileImage +import org.openedx.core.presentation.global.AppData +import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.profile.domain.model.Account +import org.openedx.profile.presentation.profile.ProfileUIState +import org.openedx.profile.presentation.ui.ProfileInfoSection +import org.openedx.profile.presentation.ui.ProfileTopic +import org.openedx.profile.domain.model.Configuration as AppConfiguration + +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun ProfileView( + windowSize: WindowSize, + uiState: ProfileUIState, + uiMessage: UIMessage?, + refreshing: Boolean, + appUpgradeEvent: AppUpgradeEvent?, + onAction: (ProfileViewAction) -> Unit, +) { + val scaffoldState = rememberScaffoldState() + var showLogoutDialog by rememberSaveable { mutableStateOf(false) } + + val pullRefreshState = rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { onAction(ProfileViewAction.SwipeRefresh) }) + + Scaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + if (showLogoutDialog) { + LogoutDialog( + onDismissRequest = { + showLogoutDialog = false + }, + onLogoutClick = { + showLogoutDialog = false + onAction(ProfileViewAction.LogoutClick) + } + ) + } + + Column( + modifier = Modifier + .padding(paddingValues) + .statusBarsInset() + .displayCutoutForLandscape(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = topBarWidth, + contentAlignment = Alignment.CenterEnd + ) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(id = R.string.core_profile), + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.titleMedium + ) + + IconText( + modifier = Modifier + .height(48.dp) + .padding(end = 24.dp), + text = stringResource(org.openedx.profile.R.string.profile_edit), + painter = painterResource(id = R.drawable.core_ic_edit), + textStyle = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary, + onClick = { + if (uiState is ProfileUIState.Data) { + onAction(ProfileViewAction.EditAccountClick) + } + } + ) + } + Surface( + color = MaterialTheme.appColors.background + ) { + Box( + modifier = Modifier.pullRefresh(pullRefreshState), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (uiState) { + is ProfileUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is ProfileUIState.Data -> { + Column( + Modifier + .fillMaxHeight() + .then(contentWidth) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ProfileTopic(uiState.account) + + Spacer(modifier = Modifier.height(36.dp)) + + ProfileInfoSection(uiState.account) + + Spacer(modifier = Modifier.height(24.dp)) + + SettingsSection(onVideoSettingsClick = { + onAction(ProfileViewAction.VideoSettingsClick) + }) + + Spacer(modifier = Modifier.height(24.dp)) + + SupportInfoSection( + uiState = uiState, + onAction = onAction, + appUpgradeEvent = appUpgradeEvent, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + LogoutButton( + onClick = { showLogoutDialog = true } + ) + + Spacer(Modifier.height(30.dp)) + } + } + } + } + PullRefreshIndicator( + refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + } + } + } + } +} + +@Composable +private fun SettingsSection(onVideoSettingsClick: () -> Unit) { + Column { + Text( + text = stringResource(id = org.openedx.profile.R.string.profile_settings), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + modifier = Modifier, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column(Modifier.fillMaxWidth()) { + ProfileInfoItem( + text = stringResource(id = org.openedx.profile.R.string.profile_video_settings), + onClick = onVideoSettingsClick + ) + } + } + } +} + +@Composable +private fun SupportInfoSection( + uiState: ProfileUIState.Data, + appUpgradeEvent: AppUpgradeEvent?, + onAction: (ProfileViewAction) -> Unit +) { + Column { + Text( + text = stringResource(id = org.openedx.profile.R.string.profile_support_info), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + modifier = Modifier, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column(Modifier.fillMaxWidth()) { + if (uiState.configuration.supportEmail.isNotBlank()) { + ProfileInfoItem(text = stringResource(id = org.openedx.profile.R.string.profile_contact_support)) { + onAction(ProfileViewAction.SupportClick) + } + Divider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.appColors.divider + ) + } + if (uiState.configuration.agreementUrls.tosUrl.isNotBlank()) { + ProfileInfoItem(text = stringResource(id = R.string.core_terms_of_use)) { + onAction(ProfileViewAction.TermsClick) + } + Divider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.appColors.divider + ) + } + if (uiState.configuration.agreementUrls.privacyPolicyUrl.isNotBlank()) { + ProfileInfoItem(text = stringResource(id = R.string.core_privacy_policy)) { + onAction(ProfileViewAction.PrivacyPolicyClick) + } + Divider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.appColors.divider + ) + } + if (uiState.configuration.agreementUrls.cookiePolicyUrl.isNotBlank()) { + ProfileInfoItem(text = stringResource(id = R.string.core_cookie_policy)) { + onAction(ProfileViewAction.CookiePolicyClick) + } + Divider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.appColors.divider + ) + } + if (uiState.configuration.agreementUrls.dataSellConsentUrl.isNotBlank()) { + ProfileInfoItem(text = stringResource(id = R.string.core_data_sell)) { + onAction(ProfileViewAction.DataSellClick) + } + Divider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.appColors.divider + ) + } + if (uiState.configuration.faqUrl.isNotBlank()) { + val uriHandler = LocalUriHandler.current + ProfileInfoItem( + text = stringResource(id = R.string.core_faq), + external = true, + ) { + uriHandler.openUri(uiState.configuration.faqUrl) + onAction(ProfileViewAction.FaqClick) + } + Divider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.appColors.divider + ) + } + AppVersionItem( + versionName = uiState.configuration.versionName, + appUpgradeEvent = appUpgradeEvent, + ) { + onAction(ProfileViewAction.AppVersionClick) + } + } + } + } +} + +@Composable +private fun LogoutButton(onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + onClick() + }, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Row( + modifier = Modifier.padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = org.openedx.profile.R.string.profile_logout), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.error + ) + Icon( + imageVector = Icons.Filled.ExitToApp, + contentDescription = null, + tint = MaterialTheme.appColors.error + ) + } + } +} + +@Composable +private fun LogoutDialog( + onDismissRequest: () -> Unit, + onLogoutClick: () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + content = { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .background( + MaterialTheme.appColors.background, + MaterialTheme.appShapes.cardShape + ) + .clip(MaterialTheme.appShapes.cardShape) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.appShapes.cardShape + ) + .padding(horizontal = 40.dp, vertical = 36.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterEnd + ) { + IconButton( + modifier = Modifier.size(24.dp), + onClick = onDismissRequest + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.core_cancel), + tint = MaterialTheme.appColors.primary + ) + } + } + Icon( + modifier = Modifier + .width(88.dp) + .height(85.dp), + painter = painterResource(org.openedx.profile.R.drawable.profile_ic_exit), + contentDescription = null, + tint = MaterialTheme.appColors.onBackground + ) + Spacer(Modifier.size(36.dp)) + Text( + text = stringResource(id = org.openedx.profile.R.string.profile_logout_dialog_body), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(Modifier.size(36.dp)) + OpenEdXButton( + text = stringResource(id = org.openedx.profile.R.string.profile_logout), + backgroundColor = MaterialTheme.appColors.warning, + onClick = onLogoutClick, + content = { + Box( + Modifier + .fillMaxWidth(), + contentAlignment = Alignment.CenterEnd + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = org.openedx.profile.R.string.profile_logout), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelLarge, + textAlign = TextAlign.Center + ) + Icon( + painter = painterResource(id = org.openedx.profile.R.drawable.profile_ic_logout), + contentDescription = null, + tint = Color.Black + ) + } + } + ) + } + } + ) +} + +@Composable +private fun ProfileInfoItem( + text: String, + external: Boolean = false, + onClick: () -> Unit +) { + val icon = if (external) { + Icons.Filled.OpenInNew + } else { + Icons.Filled.ArrowForwardIos + } + Row( + Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + Icon( + modifier = Modifier.size(16.dp), + imageVector = icon, + contentDescription = null + ) + } +} + +@Composable +private fun AppVersionItem( + versionName: String, + appUpgradeEvent: AppUpgradeEvent?, + onClick: () -> Unit +) { + Box(modifier = Modifier.padding(20.dp)) { + when (appUpgradeEvent) { + is AppUpgradeEvent.UpgradeRecommendedEvent -> { + AppVersionItemUpgradeRecommended( + versionName = versionName, + appUpgradeEvent = appUpgradeEvent, + onClick = onClick + ) + } + + is AppUpgradeEvent.UpgradeRequiredEvent -> { + AppVersionItemUpgradeRequired( + versionName = versionName, + onClick = onClick + ) + } + + else -> { + AppVersionItemAppToDate( + versionName = versionName + ) + } + } + } +} + +@Composable +private fun AppVersionItemAppToDate(versionName: String) { + Column( + Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(id = R.string.core_version, versionName), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + modifier = Modifier.size( + (MaterialTheme.appTypography.labelLarge.fontSize.value + 4).dp + ), + painter = painterResource(id = R.drawable.core_ic_check), + contentDescription = null, + tint = MaterialTheme.appColors.accessGreen + ) + Text( + text = stringResource(id = R.string.core_up_to_date), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelLarge + ) + } + } +} + +@Composable +private fun AppVersionItemUpgradeRecommended( + versionName: String, + appUpgradeEvent: AppUpgradeEvent.UpgradeRecommendedEvent, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onClick() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(id = R.string.core_version, versionName), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + Text( + text = stringResource( + id = R.string.core_tap_to_update_to_version, + appUpgradeEvent.newVersionName + ), + color = MaterialTheme.appColors.textAccent, + style = MaterialTheme.appTypography.labelLarge + ) + } + Icon( + modifier = Modifier.size(28.dp), + painter = painterResource(id = R.drawable.core_ic_icon_upgrade), + tint = MaterialTheme.appColors.primary, + contentDescription = null + ) + } +} + +@Composable +fun AppVersionItemUpgradeRequired( + versionName: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onClick() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + modifier = Modifier + .size((MaterialTheme.appTypography.labelLarge.fontSize.value + 8).dp), + painter = painterResource(id = R.drawable.core_ic_warning), + contentDescription = null + ) + Text( + text = stringResource(id = R.string.core_version, versionName), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + } + Text( + text = stringResource(id = R.string.core_tap_to_install_required_app_update), + color = MaterialTheme.appColors.textAccent, + style = MaterialTheme.appTypography.labelLarge + ) + } + Icon( + modifier = Modifier.size(28.dp), + painter = painterResource(id = R.drawable.core_ic_icon_upgrade), + tint = MaterialTheme.appColors.primary, + contentDescription = null + ) + } +} + +@Preview +@Composable +fun AppVersionItemAppToDatePreview() { + OpenEdXTheme { + AppVersionItem( + versionName = mockAppData.versionName, + appUpgradeEvent = null, + onClick = {} + ) + } +} + +@Preview +@Composable +fun AppVersionItemUpgradeRecommendedPreview() { + OpenEdXTheme { + AppVersionItem( + versionName = mockAppData.versionName, + appUpgradeEvent = AppUpgradeEvent.UpgradeRecommendedEvent("1.0.1"), + onClick = {} + ) + } +} + +@Preview +@Composable +fun AppVersionItemUpgradeRequiredPreview() { + OpenEdXTheme { + AppVersionItem( + versionName = mockAppData.versionName, + appUpgradeEvent = AppUpgradeEvent.UpgradeRequiredEvent, + onClick = {} + ) + } +} + +@Preview +@Composable +fun LogoutDialogPreview() { + LogoutDialog({}, {}) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ProfileScreenPreview() { + OpenEdXTheme { + ProfileView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = mockUiState, + uiMessage = null, + refreshing = false, + onAction = {}, + appUpgradeEvent = null, + ) + } +} + + +@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ProfileScreenTabletPreview() { + OpenEdXTheme { + ProfileView( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = mockUiState, + uiMessage = null, + refreshing = false, + onAction = {}, + appUpgradeEvent = null, + ) + } +} + +private val mockAppData = AppData( + versionName = "1.0.0", +) + +private val mockAccount = Account( + username = "thom84", + bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", + requiresParentalConsent = true, + name = "Thomas", + country = "Ukraine", + isActive = true, + profileImage = ProfileImage("", "", "", "", false), + yearOfBirth = 2000, + levelOfEducation = "Bachelor", + goals = "130", + languageProficiencies = emptyList(), + gender = "male", + mailingAddress = "", + "", + null, + accountPrivacy = Account.Privacy.ALL_USERS +) + +private val mockConfiguration = AppConfiguration( + agreementUrls = AgreementUrls(), + faqUrl = "https://example.com/faq", + supportEmail = "test@example.com", + versionName = mockAppData.versionName, +) + +private val mockUiState = ProfileUIState.Data( + account = mockAccount, + configuration = mockConfiguration, +) + +internal interface ProfileViewAction { + object AppVersionClick : ProfileViewAction + object EditAccountClick : ProfileViewAction + object LogoutClick : ProfileViewAction + object PrivacyPolicyClick : ProfileViewAction + object CookiePolicyClick : ProfileViewAction + object DataSellClick : ProfileViewAction + object FaqClick : ProfileViewAction + object TermsClick : ProfileViewAction + object SupportClick : ProfileViewAction + object VideoSettingsClick : ProfileViewAction + object SwipeRefresh : ProfileViewAction +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt index ccda6e9d2..46c645a76 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt @@ -7,6 +7,8 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Done @@ -108,7 +110,8 @@ private fun VideoQualityScreen( Column( modifier = Modifier .padding(paddingValues) - .statusBarsInset(), + .statusBarsInset() + .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { Box( @@ -118,7 +121,7 @@ private fun VideoQualityScreen( Text( modifier = Modifier .fillMaxWidth(), - text = stringResource(id = profileR.string.profile_video_download_quality), + text = stringResource(id = profileR.string.profile_video_streaming_quality), color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center, style = MaterialTheme.appTypography.titleMedium @@ -129,7 +132,9 @@ private fun VideoQualityScreen( } Column( - modifier = Modifier.then(contentWidth), + modifier = Modifier + .then(contentWidth) + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { val autoQuality = diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt index 8816decd9..747df792a 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt @@ -123,7 +123,8 @@ private fun VideoSettingsScreen( Column( modifier = Modifier .padding(paddingValues) - .statusBarsInset(), + .statusBarsInset() + .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally ) { Box( @@ -197,7 +198,7 @@ private fun VideoSettingsScreen( ) { Column(Modifier.weight(1f)) { Text( - text = stringResource(id = profileR.string.profile_video_download_quality), + text = stringResource(id = profileR.string.profile_video_streaming_quality), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) diff --git a/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt b/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt new file mode 100644 index 000000000..9dceab592 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt @@ -0,0 +1,148 @@ +package org.openedx.profile.presentation.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.openedx.core.R +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.profile.domain.model.Account + +@Composable +fun ProfileTopic(account: Account) { + Column( + Modifier.fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val profileImage = if (account.profileImage.hasImage) { + account.profileImage.imageUrlFull + } else { + R.drawable.core_ic_default_profile_picture + } + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(profileImage) + .error(R.drawable.core_ic_default_profile_picture) + .placeholder(R.drawable.core_ic_default_profile_picture) + .build(), + contentDescription = stringResource(id = R.string.core_accessibility_user_profile_image, account.username), + modifier = Modifier + .border( + 2.dp, + MaterialTheme.appColors.onSurface, + CircleShape + ) + .padding(2.dp) + .size(100.dp) + .clip(CircleShape) + ) + if (account.name.isNotEmpty()) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = account.name, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.headlineSmall + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "@${account.username}", + color = MaterialTheme.appColors.textPrimaryVariant, + style = MaterialTheme.appTypography.labelLarge + ) + } +} + +@Composable +fun ProfileInfoSection(account: Account) { + + if (account.yearOfBirth != null || account.bio.isNotEmpty()) { + Column { + Text( + text = stringResource(id = org.openedx.profile.R.string.profile_prof_info), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + modifier = Modifier, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column( + Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (account.yearOfBirth != null) { + Text( + text = buildAnnotatedString { + val value = if (account.yearOfBirth != null) { + account.yearOfBirth.toString() + } else "" + val text = stringResource( + id = org.openedx.profile.R.string.profile_year_of_birth, + value + ) + append(text) + addStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textPrimaryVariant + ), + start = 0, + end = text.length - value.length + ) + }, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + } + if (account.bio.isNotEmpty()) { + Text( + text = buildAnnotatedString { + val text = stringResource( + id = org.openedx.profile.R.string.profile_bio, + account.bio + ) + append(text) + addStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textPrimaryVariant + ), + start = 0, + end = text.length - account.bio.length + ) + }, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/profile/src/main/res/values-uk/strings.xml b/profile/src/main/res/values-uk/strings.xml index 123d4aee6..1cbb0a60a 100644 --- a/profile/src/main/res/values-uk/strings.xml +++ b/profile/src/main/res/values-uk/strings.xml @@ -30,7 +30,7 @@ Налаштування відео Завантаження тільки через Wi-Fi Завантажуйте вміст лише тоді, коли ввімкнено wi-fi - Якість завантаження відео + Якість транслювання відео Видалити акаунт Ви впевнені, що бажаєте видалити свій акаунт? diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 9ad0c47c9..03f82fa8a 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -38,7 +38,7 @@ Video settings Wi-fi only download Only download content when wi-fi is turned on - Video download quality + Video streaming quality Leave profile? Leave Keep editing diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt new file mode 100644 index 000000000..990248b2e --- /dev/null +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt @@ -0,0 +1,121 @@ +package org.openedx.profile.presentation.profile + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.ProfileImage +import org.openedx.core.system.ResourceManager +import org.openedx.profile.domain.interactor.ProfileInteractor +import org.openedx.profile.presentation.anothers_account.AnothersProfileUIState +import org.openedx.profile.presentation.anothers_account.AnothersProfileViewModel +import java.net.UnknownHostException + +@OptIn(ExperimentalCoroutinesApi::class) +class AnothersProfileViewModelTest { + + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + private val resourceManager = mockk() + private val interactor = mockk() + private val username = "username" + + private val account = org.openedx.profile.domain.model.Account( + username = "", + bio = "", + requiresParentalConsent = false, + name = "", + country = "", + isActive = true, + profileImage = ProfileImage("", "", "", "", false), + yearOfBirth = 2000, + levelOfEducation = "", + goals = "", + languageProficiencies = emptyList(), + gender = "", + mailingAddress = "", + email = "", + dateJoined = null, + accountPrivacy = org.openedx.profile.domain.model.Account.Privacy.PRIVATE + ) + + private val noInternet = "Slow or no internet connection" + private val somethingWrong = "Something went wrong" + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getAccount no internetConnection`() = runTest { + val viewModel = AnothersProfileViewModel( + interactor, + resourceManager, + username + ) + coEvery { interactor.getAccount(username) } throws UnknownHostException() + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getAccount(username) } + + val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + assert(viewModel.uiState.value is AnothersProfileUIState.Loading) + assertEquals(noInternet, message?.message) + } + + @Test + fun `getAccount unknown exception`() = runTest { + val viewModel = AnothersProfileViewModel( + interactor, + resourceManager, + username + ) + coEvery { interactor.getAccount(username) } throws Exception() + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getAccount(username) } + + val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + assert(viewModel.uiState.value is AnothersProfileUIState.Loading) + assertEquals(somethingWrong, message?.message) + } + + @Test + fun `getAccount success`() = runTest { + val viewModel = AnothersProfileViewModel( + interactor, + resourceManager, + username + ) + coEvery { interactor.getAccount(username) } returns account + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getAccount(username) } + + assert(viewModel.uiState.value is AnothersProfileUIState.Data) + assert(viewModel.uiMessage.value == null) + } +} \ No newline at end of file diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt index fa763d41f..45d346671 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt @@ -1,19 +1,10 @@ package org.openedx.profile.presentation.profile import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.ui.text.intl.Locale import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.profile.domain.model.Account -import org.openedx.core.module.DownloadWorkerController -import org.openedx.core.system.AppCookieManager -import org.openedx.core.system.ResourceManager -import org.openedx.profile.domain.interactor.ProfileInteractor -import org.openedx.profile.presentation.ProfileAnalytics -import org.openedx.profile.system.notifier.AccountUpdated -import org.openedx.profile.system.notifier.ProfileNotifier import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -21,6 +12,7 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.* import org.junit.After @@ -29,8 +21,21 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule -import org.openedx.core.data.storage.CorePreferences -import org.openedx.profile.data.storage.ProfilePreferences +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.domain.model.AgreementUrls +import org.openedx.core.domain.model.ProfileImage +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.presentation.global.AppData +import org.openedx.core.system.AppCookieManager +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.profile.domain.interactor.ProfileInteractor +import org.openedx.profile.presentation.ProfileAnalytics +import org.openedx.profile.presentation.ProfileRouter +import org.openedx.profile.system.notifier.AccountUpdated +import org.openedx.profile.system.notifier.ProfileNotifier import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -40,17 +45,39 @@ class ProfileViewModelTest { val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() private val dispatcher = StandardTestDispatcher() - private val dispatcherIO = UnconfinedTestDispatcher() + private val config = mockk() private val resourceManager = mockk() - private val preferencesManager = mockk() private val interactor = mockk() private val notifier = mockk() private val cookieManager = mockk() private val workerController = mockk() private val analytics = mockk() - - private val account = mockk() + private val router = mockk() + private val appUpgradeNotifier = mockk() + + private val appData = AppData( + versionName = "1.0.0", + ) + + private val account = org.openedx.profile.domain.model.Account( + username = "", + bio = "", + requiresParentalConsent = false, + name = "", + country = "", + isActive = true, + profileImage = ProfileImage("", "", "", "", false), + yearOfBirth = 2000, + levelOfEducation = "", + goals = "", + languageProficiencies = emptyList(), + gender = "", + mailingAddress = "", + email = "", + dateJoined = null, + accountPrivacy = org.openedx.profile.domain.model.Account.Privacy.PRIVATE + ) private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -60,6 +87,11 @@ class ProfileViewModelTest { Dispatchers.setMain(dispatcher) 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 { config.isPreLoginExperienceEnabled() } returns false + every { config.getFeedbackEmailAddress() } returns "" + every { config.getAgreement(Locale.current.language) } returns AgreementUrls() + every { config.getFaqUrl() } returns "" } @After @@ -69,22 +101,25 @@ class ProfileViewModelTest { @Test fun `getAccount no internetConnection and cache is null`() = runTest { - val viewModel = - ProfileViewModel( - interactor, - preferencesManager, - resourceManager, - notifier, - dispatcher, - cookieManager, - workerController, - analytics - ) - coEvery { preferencesManager.profile } returns null + val viewModel = ProfileViewModel( + appData, + config, + interactor, + resourceManager, + notifier, + dispatcher, + cookieManager, + workerController, + analytics, + router, + appUpgradeNotifier + ) + coEvery { interactor.getCachedAccount() } returns null coEvery { interactor.getAccount() } throws UnknownHostException() advanceUntilIdle() coVerify(exactly = 1) { interactor.getAccount() } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assert(viewModel.uiState.value is ProfileUIState.Loading) @@ -93,22 +128,25 @@ class ProfileViewModelTest { @Test fun `getAccount no internetConnection and cache is not null`() = runTest { - val viewModel = - ProfileViewModel( - interactor, - preferencesManager, - resourceManager, - notifier, - dispatcher, - cookieManager, - workerController, - analytics - ) - coEvery { preferencesManager.profile } returns account + val viewModel = ProfileViewModel( + appData, + config, + interactor, + resourceManager, + notifier, + dispatcher, + cookieManager, + workerController, + analytics, + router, + appUpgradeNotifier + ) + coEvery { interactor.getCachedAccount() } returns account coEvery { interactor.getAccount() } throws UnknownHostException() advanceUntilIdle() coVerify(exactly = 1) { interactor.getAccount() } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assert(viewModel.uiState.value is ProfileUIState.Data) @@ -117,22 +155,25 @@ class ProfileViewModelTest { @Test fun `getAccount unknown exception`() = runTest { - val viewModel = - ProfileViewModel( - interactor, - preferencesManager, - resourceManager, - notifier, - dispatcher, - cookieManager, - workerController, - analytics - ) - coEvery { preferencesManager.profile } returns null + val viewModel = ProfileViewModel( + appData, + config, + interactor, + resourceManager, + notifier, + dispatcher, + cookieManager, + workerController, + analytics, + router, + appUpgradeNotifier + ) + coEvery { interactor.getCachedAccount() } returns null coEvery { interactor.getAccount() } throws Exception() advanceUntilIdle() coVerify(exactly = 1) { interactor.getAccount() } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assert(viewModel.uiState.value is ProfileUIState.Loading) @@ -141,23 +182,25 @@ class ProfileViewModelTest { @Test fun `getAccount success`() = runTest { - val viewModel = - ProfileViewModel( - interactor, - preferencesManager, - resourceManager, - notifier, - dispatcher, - cookieManager, - workerController, - analytics - ) - coEvery { preferencesManager.profile } returns null + val viewModel = ProfileViewModel( + appData, + config, + interactor, + resourceManager, + notifier, + dispatcher, + cookieManager, + workerController, + analytics, + router, + appUpgradeNotifier + ) + coEvery { interactor.getCachedAccount() } returns null coEvery { interactor.getAccount() } returns account - every { preferencesManager.profile = any() } returns Unit advanceUntilIdle() coVerify(exactly = 1) { interactor.getAccount() } + verify(exactly = 1) { appUpgradeNotifier.notifier } assert(viewModel.uiState.value is ProfileUIState.Data) assert(viewModel.uiMessage.value == null) @@ -165,71 +208,84 @@ class ProfileViewModelTest { @Test fun `logout no internet connection`() = runTest { - val viewModel = - ProfileViewModel( - interactor, - preferencesManager, - resourceManager, - notifier, - dispatcher, - cookieManager, - workerController, - analytics - ) + val viewModel = ProfileViewModel( + appData, + config, + interactor, + resourceManager, + notifier, + dispatcher, + cookieManager, + workerController, + analytics, + router, + appUpgradeNotifier + ) coEvery { interactor.logout() } throws UnknownHostException() coEvery { workerController.cancelWork() } returns Unit - + every { analytics.logoutEvent(false) } returns Unit + every { cookieManager.clearWebViewCookie() } returns Unit viewModel.logout() advanceUntilIdle() coVerify(exactly = 1) { interactor.logout() } + verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) - assert(viewModel.successLogout.value == null) + assert(viewModel.successLogout.value == true) } @Test fun `logout unknown exception`() = runTest { - val viewModel = - ProfileViewModel( - interactor, - preferencesManager, - resourceManager, - notifier, - dispatcher, - cookieManager, - workerController, - analytics - ) + val viewModel = ProfileViewModel( + appData, + config, + interactor, + resourceManager, + notifier, + dispatcher, + cookieManager, + workerController, + analytics, + router, + appUpgradeNotifier + ) coEvery { interactor.logout() } throws Exception() coEvery { workerController.cancelWork() } returns Unit + every { analytics.logoutEvent(false) } returns Unit + every { cookieManager.clearWebViewCookie() } returns Unit viewModel.logout() advanceUntilIdle() coVerify(exactly = 1) { interactor.logout() } + verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { cookieManager.clearWebViewCookie() } + verify { analytics.logoutEvent(false) } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) - assert(viewModel.successLogout.value == null) + assert(viewModel.successLogout.value == true) } @Test fun `logout success`() = runTest { val viewModel = ProfileViewModel( + appData, + config, interactor, - preferencesManager, resourceManager, notifier, dispatcher, cookieManager, workerController, - analytics + analytics, + router, + appUpgradeNotifier ) - coEvery { preferencesManager.profile } returns mockk() + coEvery { interactor.getCachedAccount() } returns mockk() coEvery { interactor.getAccount() } returns mockk() every { analytics.logoutEvent(false) } returns Unit - every { preferencesManager.profile = any() } returns Unit coEvery { interactor.logout() } returns Unit coEvery { workerController.cancelWork() } returns Unit every { cookieManager.clearWebViewCookie() } returns Unit @@ -237,6 +293,8 @@ class ProfileViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.logout() } verify { analytics.logoutEvent(false) } + verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { cookieManager.clearWebViewCookie() } assert(viewModel.uiMessage.value == null) assert(viewModel.successLogout.value == true) @@ -245,16 +303,19 @@ class ProfileViewModelTest { @Test fun `AccountUpdated notifier test`() = runTest { val viewModel = ProfileViewModel( + appData, + config, interactor, - preferencesManager, resourceManager, notifier, dispatcher, cookieManager, workerController, - analytics + analytics, + router, + appUpgradeNotifier ) - coEvery { preferencesManager.profile } returns null + coEvery { interactor.getCachedAccount() } returns null every { notifier.notifier } returns flow { emit(AccountUpdated()) } val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -264,6 +325,6 @@ class ProfileViewModelTest { advanceUntilIdle() coVerify(exactly = 2) { interactor.getAccount() } + verify(exactly = 1) { appUpgradeNotifier.notifier } } - -} \ No newline at end of file +} diff --git a/settings.gradle b/settings.gradle index 1bb570281..e0a869615 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,12 +4,24 @@ pluginManagement { google() mavenCentral() } + buildscript { + repositories { + mavenCentral() + maven { + url = uri("https://storage.googleapis.com/r8-releases/raw") + } + } + dependencies { + classpath("com.android.tools:r8:8.2.26") + } + } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() + maven { url "https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1" } } } rootProject.name = "OpenEdX" @@ -21,3 +33,4 @@ include ':dashboard' include ':discovery' include ':profile' include ':discussion' +include ':whatsnew' diff --git a/whatsnew/.gitignore b/whatsnew/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/whatsnew/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/whatsnew/build.gradle b/whatsnew/build.gradle new file mode 100644 index 000000000..4a400063e --- /dev/null +++ b/whatsnew/build.gradle @@ -0,0 +1,68 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'kotlin-parcelize' +} + +android { + namespace 'org.openedx.whatsnew' + compileSdk 34 + + defaultConfig { + minSdk 24 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } + + buildFeatures { + viewBinding true + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = "$compose_compiler_version" + } + + flavorDimensions += "env" + productFlavors { + prod { + dimension 'env' + } + develop { + dimension 'env' + } + stage { + dimension 'env' + } + } +} + +dependencies { + implementation project(path: ":core") + + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + testImplementation "junit:junit:$junit_version" + testImplementation "io.mockk:mockk:$mockk_version" + testImplementation "io.mockk:mockk-android:$mockk_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" + testImplementation "androidx.arch.core:core-testing:$android_arch_version" +} \ No newline at end of file diff --git a/whatsnew/consumer-rules.pro b/whatsnew/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/whatsnew/proguard-rules.pro b/whatsnew/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/whatsnew/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/whatsnew/src/androidTest/java/org/openedx/whatsnew/ExampleInstrumentedTest.kt b/whatsnew/src/androidTest/java/org/openedx/whatsnew/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..5b65b0c9d --- /dev/null +++ b/whatsnew/src/androidTest/java/org/openedx/whatsnew/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package org.openedx.whatsnew + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("org.openedx.whatsnew.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/whatsnew/src/main/AndroidManifest.xml b/whatsnew/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/whatsnew/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewManager.kt b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewManager.kt new file mode 100644 index 000000000..71a51d3b6 --- /dev/null +++ b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewManager.kt @@ -0,0 +1,31 @@ +package org.openedx.whatsnew + +import android.content.Context +import com.google.gson.Gson +import org.openedx.core.config.Config +import org.openedx.core.presentation.global.AppData +import org.openedx.core.presentation.global.WhatsNewGlobalManager +import org.openedx.whatsnew.data.model.WhatsNewItem +import org.openedx.whatsnew.data.storage.WhatsNewPreferences + +class WhatsNewManager( + private val context: Context, + private val config: Config, + private val whatsNewPreferences: WhatsNewPreferences, + private val appData: AppData +) : WhatsNewGlobalManager { + fun getNewestData(): org.openedx.whatsnew.domain.model.WhatsNewItem { + val jsonString = context.resources.openRawResource(R.raw.whats_new) + .bufferedReader() + .use { it.readText() } + val whatsNewListData = Gson().fromJson(jsonString, Array::class.java) + return whatsNewListData[0].mapToDomain(context) + } + + override fun shouldShowWhatsNew(): Boolean { + val dataVersion = getNewestData().version + return appData.versionName == dataVersion + && whatsNewPreferences.lastWhatsNewVersion != dataVersion + && config.isWhatsNewEnabled() + } +} diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt new file mode 100644 index 000000000..46bbe3d3d --- /dev/null +++ b/whatsnew/src/main/java/org/openedx/whatsnew/WhatsNewRouter.kt @@ -0,0 +1,7 @@ +package org.openedx.whatsnew + +import androidx.fragment.app.FragmentManager + +interface WhatsNewRouter { + fun navigateToMain(fm: FragmentManager, courseId: String? = null) +} diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/data/model/WhatsNewItem.kt b/whatsnew/src/main/java/org/openedx/whatsnew/data/model/WhatsNewItem.kt new file mode 100644 index 000000000..e0c029d53 --- /dev/null +++ b/whatsnew/src/main/java/org/openedx/whatsnew/data/model/WhatsNewItem.kt @@ -0,0 +1,16 @@ +package org.openedx.whatsnew.data.model + +import android.content.Context +import com.google.gson.annotations.SerializedName + +data class WhatsNewItem( + @SerializedName("version") + val version: String, + @SerializedName("messages") + val messages: List +) { + fun mapToDomain(context: Context) = org.openedx.whatsnew.domain.model.WhatsNewItem( + version = version, + messages = messages.map { it.mapToDomain(context) } + ) +} \ No newline at end of file diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/data/model/WhatsNewMessage.kt b/whatsnew/src/main/java/org/openedx/whatsnew/data/model/WhatsNewMessage.kt new file mode 100644 index 000000000..da9b0676d --- /dev/null +++ b/whatsnew/src/main/java/org/openedx/whatsnew/data/model/WhatsNewMessage.kt @@ -0,0 +1,28 @@ +package org.openedx.whatsnew.data.model + +import android.content.Context +import com.google.gson.annotations.SerializedName + +data class WhatsNewMessage( + @SerializedName("image") + val image: String, + @SerializedName("title") + val title: String, + @SerializedName("message") + val message: String +) { + fun mapToDomain(context: Context) = org.openedx.whatsnew.domain.model.WhatsNewMessage( + image = getDrawableIntFromString(context, image), + title = title, + message = message + ) + + private fun getDrawableIntFromString(context: Context, imageName: String): Int { + val imageInt = context.resources.getIdentifier(imageName, "drawable", context.packageName) + return if (imageInt == 0) { + org.openedx.core.R.drawable.core_no_image_course + } else { + imageInt + } + } +} diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/data/storage/WhatsNewPreferences.kt b/whatsnew/src/main/java/org/openedx/whatsnew/data/storage/WhatsNewPreferences.kt new file mode 100644 index 000000000..6270f809e --- /dev/null +++ b/whatsnew/src/main/java/org/openedx/whatsnew/data/storage/WhatsNewPreferences.kt @@ -0,0 +1,5 @@ +package org.openedx.whatsnew.data.storage + +interface WhatsNewPreferences { + var lastWhatsNewVersion: String +} \ No newline at end of file diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/domain/model/WhatsNewItem.kt b/whatsnew/src/main/java/org/openedx/whatsnew/domain/model/WhatsNewItem.kt new file mode 100644 index 000000000..7b59b54d8 --- /dev/null +++ b/whatsnew/src/main/java/org/openedx/whatsnew/domain/model/WhatsNewItem.kt @@ -0,0 +1,6 @@ +package org.openedx.whatsnew.domain.model + +data class WhatsNewItem( + val version: String, + val messages: List +) diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/domain/model/WhatsNewMessage.kt b/whatsnew/src/main/java/org/openedx/whatsnew/domain/model/WhatsNewMessage.kt new file mode 100644 index 000000000..5f311a23a --- /dev/null +++ b/whatsnew/src/main/java/org/openedx/whatsnew/domain/model/WhatsNewMessage.kt @@ -0,0 +1,10 @@ +package org.openedx.whatsnew.domain.model + +import androidx.annotation.DrawableRes + +data class WhatsNewMessage( + @DrawableRes + val image: Int, + val title: String, + val message: String +) diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt new file mode 100644 index 000000000..a76ff9a10 --- /dev/null +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt @@ -0,0 +1,314 @@ +package org.openedx.whatsnew.presentation.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.whatsnew.R + +@Composable +fun PageIndicator( + numberOfPages: Int, + modifier: Modifier = Modifier, + selectedPage: Int = 0, + selectedColor: Color = MaterialTheme.appColors.info, + previousUnselectedColor: Color = MaterialTheme.appColors.cardViewBorder, + nextUnselectedColor: Color = MaterialTheme.appColors.textFieldBorder, + defaultRadius: Dp = 20.dp, + selectedLength: Dp = 60.dp, + space: Dp = 30.dp, + animationDurationInMillis: Int = 300, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space), + modifier = modifier, + ) { + for (i in 0 until numberOfPages) { + val isSelected = i == selectedPage + val unselectedColor = + if (i < selectedPage) previousUnselectedColor else nextUnselectedColor + PageIndicatorView( + isSelected = isSelected, + selectedColor = selectedColor, + defaultColor = unselectedColor, + defaultRadius = defaultRadius, + selectedLength = selectedLength, + animationDurationInMillis = animationDurationInMillis, + ) + } + } +} + +@Composable +fun PageIndicatorView( + isSelected: Boolean, + selectedColor: Color, + defaultColor: Color, + defaultRadius: Dp, + selectedLength: Dp, + animationDurationInMillis: Int, + modifier: Modifier = Modifier, +) { + + val color: Color by animateColorAsState( + targetValue = if (isSelected) { + selectedColor + } else { + defaultColor + }, + animationSpec = tween( + durationMillis = animationDurationInMillis, + ), + label = "" + ) + val width: Dp by animateDpAsState( + targetValue = if (isSelected) { + selectedLength + } else { + defaultRadius + }, + animationSpec = tween( + durationMillis = animationDurationInMillis, + ), + label = "" + ) + + Canvas( + modifier = modifier + .size( + width = width, + height = defaultRadius, + ), + ) { + drawRoundRect( + color = color, + topLeft = Offset.Zero, + size = Size( + width = width.toPx(), + height = defaultRadius.toPx(), + ), + cornerRadius = CornerRadius( + x = defaultRadius.toPx(), + y = defaultRadius.toPx(), + ), + ) + } +} + +@Composable +fun NavigationUnitsButtons( + hasPrevPage: Boolean, + hasNextPage: Boolean, + onPrevClick: () -> Unit, + onNextClick: () -> Unit +) { + Row( + modifier = Modifier.padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + PrevButton( + hasPrevPage = hasPrevPage, + onPrevClick = onPrevClick + ) + NextFinishButton( + hasNextPage = hasNextPage, + onNextClick = onNextClick + ) + } +} + +@Composable +fun PrevButton( + hasPrevPage: Boolean, + onPrevClick: () -> Unit +) { + val prevButtonAnimationFactor by animateFloatAsState( + targetValue = if (hasPrevPage) 1f else 0f, + animationSpec = tween(300), + label = "" + ) + + OutlinedButton( + modifier = Modifier + .testTag("btn_previous") + .height(42.dp) + .alpha(prevButtonAnimationFactor), + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = MaterialTheme.appColors.background + ), + border = BorderStroke(1.dp, MaterialTheme.appColors.primary), + elevation = null, + shape = MaterialTheme.appShapes.navigationButtonShape, + onClick = onPrevClick, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_back), + contentDescription = null, + tint = MaterialTheme.appColors.primary + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.whats_new_navigation_previous), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge + ) + } + } +} + +@Composable +fun NextFinishButton( + onNextClick: () -> Unit, + hasNextPage: Boolean +) { + Button( + modifier = Modifier + .testTag("btn_next") + .height(42.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.appColors.buttonBackground + ), + elevation = null, + shape = MaterialTheme.appShapes.navigationButtonShape, + onClick = onNextClick + ) { + AnimatedContent( + targetState = hasNextPage, + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + label = "" + ) { hasNextPage -> + if (hasNextPage) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier.testTag("txt_next"), + text = stringResource(id = R.string.whats_new_navigation_next), + color = MaterialTheme.appColors.buttonText, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(Modifier.width(8.dp)) + Icon( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_forward), + contentDescription = null, + tint = MaterialTheme.appColors.buttonText + ) + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier.testTag("txt_done"), + text = stringResource(id = R.string.whats_new_navigation_done), + color = MaterialTheme.appColors.buttonText, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(Modifier.width(8.dp)) + Icon( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_check), + contentDescription = null, + tint = MaterialTheme.appColors.buttonText + ) + } + } + } + } +} + +@Preview +@Composable +private fun NavigationUnitsButtonsPrevInTheMiddle() { + OpenEdXTheme { + NavigationUnitsButtons( + hasPrevPage = true, + hasNextPage = true, + onPrevClick = {}, + onNextClick = {} + ) + } +} + +@Preview +@Composable +private fun NavigationUnitsButtonsPrevInTheStart() { + OpenEdXTheme { + NavigationUnitsButtons( + hasPrevPage = false, + hasNextPage = true, + onPrevClick = {}, + onNextClick = {} + ) + } +} + +@Preview +@Composable +private fun NavigationUnitsButtonsPrevInTheEnd() { + OpenEdXTheme { + NavigationUnitsButtons( + hasPrevPage = true, + hasNextPage = false, + onPrevClick = {}, + onNextClick = {} + ) + } +} + +@Preview +@Composable +private fun PageIndicatorViewPreview() { + OpenEdXTheme { + PageIndicator( + numberOfPages = 4, selectedPage = 2 + ) + } +} \ No newline at end of file diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt new file mode 100644 index 000000000..d1d69b861 --- /dev/null +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt @@ -0,0 +1,496 @@ +package org.openedx.whatsnew.presentation.whatsnew + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +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.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.openedx.core.presentation.global.AppData +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.calculateCurrentOffsetForPage +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.whatsnew.WhatsNewRouter +import org.openedx.whatsnew.data.storage.WhatsNewPreferences +import org.openedx.whatsnew.domain.model.WhatsNewItem +import org.openedx.whatsnew.domain.model.WhatsNewMessage +import org.openedx.whatsnew.presentation.ui.NavigationUnitsButtons +import org.openedx.whatsnew.presentation.ui.PageIndicator + +class WhatsNewFragment : Fragment() { + + private val viewModel: WhatsNewViewModel by viewModel { + parametersOf(requireArguments().getString(ARG_COURSE_ID, null)) + } + private val preferencesManager by inject() + private val router by inject() + private val appData: AppData by inject() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val whatsNewItem = viewModel.whatsNewItem + WhatsNewScreen( + windowSize = windowSize, + whatsNewItem = whatsNewItem.value, + onCloseClick = { + val versionName = appData.versionName + preferencesManager.lastWhatsNewVersion = versionName + router.navigateToMain(parentFragmentManager, viewModel.courseId) + } + ) + } + } + } + + companion object { + private const val ARG_COURSE_ID = "courseId" + fun newInstance(courseId: String? = null): WhatsNewFragment { + val fragment = WhatsNewFragment() + fragment.arguments = bundleOf( + ARG_COURSE_ID to courseId + ) + return fragment + } + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@Composable +fun WhatsNewScreen( + windowSize: WindowSize, + whatsNewItem: WhatsNewItem?, + onCloseClick: () -> Unit +) { + whatsNewItem?.let { item -> + OpenEdXTheme { + val scaffoldState = rememberScaffoldState() + val pagerState = rememberPagerState { + whatsNewItem.messages.size + } + + Scaffold( + modifier = Modifier + .semantics { + testTagsAsResourceId = true + } + .fillMaxSize(), + scaffoldState = scaffoldState, + topBar = { + WhatsNewTopBar( + windowSize = windowSize, + onCloseClick = onCloseClick + ) + }, + content = { paddingValues -> + val configuration = LocalConfiguration.current + when (configuration.orientation) { + Configuration.ORIENTATION_LANDSCAPE -> + WhatsNewScreenLandscape( + modifier = Modifier.padding(paddingValues), + whatsNewItem = item, + pagerState = pagerState, + onCloseClick = onCloseClick + ) + + else -> + WhatsNewScreenPortrait( + modifier = Modifier.padding(paddingValues), + whatsNewItem = item, + pagerState = pagerState, + onCloseClick = onCloseClick + ) + } + } + ) + } + } +} + +@Composable +private fun WhatsNewTopBar( + windowSize: WindowSize, + onCloseClick: () -> Unit +) { + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + OpenEdXTheme { + Column( + Modifier + .fillMaxWidth() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .then(topBarWidth), + contentAlignment = Alignment.CenterEnd + ) { + Text( + modifier = Modifier + .testTag("txt_screen_title") + .fillMaxWidth(), + text = stringResource(id = org.openedx.whatsnew.R.string.whats_new_title), + textAlign = TextAlign.Center, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + IconButton( + modifier = Modifier + .testTag("ib_close") + .padding(end = 16.dp), + onClick = onCloseClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = org.openedx.core.R.string.core_cancel), + tint = MaterialTheme.appColors.primary + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun WhatsNewScreenPortrait( + modifier: Modifier = Modifier, + whatsNewItem: WhatsNewItem, + pagerState: PagerState, + onCloseClick: () -> Unit +) { + OpenEdXTheme { + val coroutineScope = rememberCoroutineScope() + val message = whatsNewItem.messages[pagerState.currentPage] + + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.appColors.background), + contentAlignment = Alignment.TopCenter + ) { + HorizontalPager( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.Top, + state = pagerState + ) { page -> + val image = whatsNewItem.messages[page].image + Image( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 36.dp, vertical = 48.dp), + painter = painterResource(id = image), + contentDescription = null + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 120.dp), + contentAlignment = Alignment.BottomCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + PageIndicator( + numberOfPages = pagerState.pageCount, + selectedPage = pagerState.currentPage, + defaultRadius = 12.dp, + selectedLength = 24.dp, + space = 4.dp, + animationDurationInMillis = 500, + ) + + Crossfade( + targetState = message, + modifier = Modifier.fillMaxWidth(), + label = "" + ) { targetText -> + Column( + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + modifier = Modifier + .testTag("txt_whats_new_title") + .fillMaxWidth(), + text = targetText.title, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.titleMedium + ) + Text( + modifier = Modifier + .testTag("txt_whats_new_description") + .fillMaxWidth() + .height(80.dp), + text = targetText.message, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.bodyMedium + ) + } + } + + NavigationUnitsButtons( + hasPrevPage = pagerState.canScrollBackward && pagerState.currentPage != 0, + hasNextPage = pagerState.canScrollForward, + onPrevClick = remember { + { + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } + } + }, + onNextClick = remember { + { + if (pagerState.canScrollForward) { + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } else { + onCloseClick() + } + } + } + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun WhatsNewScreenLandscape( + modifier: Modifier = Modifier, + whatsNewItem: WhatsNewItem, + pagerState: PagerState, + onCloseClick: () -> Unit +) { + OpenEdXTheme { + val coroutineScope = rememberCoroutineScope() + val message = whatsNewItem.messages[pagerState.currentPage] + + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.appColors.background), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterEnd + ) { + HorizontalPager( + verticalAlignment = Alignment.CenterVertically, + state = pagerState + ) { page -> + val image = whatsNewItem.messages[page].image + val alpha = (0.2f + pagerState.calculateCurrentOffsetForPage(page)) * 10 + Image( + modifier = Modifier + .alpha(alpha) + .fillMaxHeight() + .padding(vertical = 24.dp) + .padding(start = 140.dp), + painter = painterResource(id = image), + contentDescription = null + ) + } + Box( + modifier = Modifier + .fillMaxHeight() + .width(400.dp) + .padding(end = 140.dp), + contentAlignment = Alignment.CenterEnd + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Crossfade( + targetState = message, + modifier = Modifier.fillMaxWidth(), + label = "" + ) { targetText -> + Column( + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = targetText.title, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.titleMedium + ) + Text( + modifier = Modifier + .fillMaxWidth() + .height(80.dp), + text = targetText.message, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.bodyMedium + ) + } + } + + NavigationUnitsButtons( + hasPrevPage = pagerState.canScrollBackward && pagerState.currentPage != 0, + hasNextPage = pagerState.canScrollForward, + onPrevClick = remember { + { + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } + } + }, + onNextClick = remember { + { + if (pagerState.canScrollForward) { + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } else { + onCloseClick() + } + } + } + ) + } + } + } + + PageIndicator( + modifier = Modifier.weight(0.25f), + numberOfPages = pagerState.pageCount, + selectedPage = pagerState.currentPage, + defaultRadius = 12.dp, + selectedLength = 24.dp, + space = 4.dp, + animationDurationInMillis = 500, + ) + } + } +} + +val whatsNewMessagePreview = WhatsNewMessage( + image = org.openedx.core.R.drawable.core_no_image_course, + title = "title", + message = "Message message message" +) +val whatsNewItemPreview = WhatsNewItem( + version = "1.0", + messages = listOf(whatsNewMessagePreview, whatsNewMessagePreview, whatsNewMessagePreview) +) + +@OptIn(ExperimentalFoundationApi::class) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun WhatsNewPortraitPreview() { + OpenEdXTheme { + WhatsNewScreenPortrait( + whatsNewItem = whatsNewItemPreview, + onCloseClick = {}, + pagerState = rememberPagerState { 4 } + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_NO, + device = Devices.AUTOMOTIVE_1024p, + widthDp = 720, + heightDp = 360 +) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + device = Devices.AUTOMOTIVE_1024p, + widthDp = 720, + heightDp = 360 +) +@Composable +private fun WhatsNewLandscapePreview() { + OpenEdXTheme { + WhatsNewScreenLandscape( + whatsNewItem = whatsNewItemPreview, + onCloseClick = {}, + pagerState = rememberPagerState { 4 } + ) + } +} diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt new file mode 100644 index 000000000..c27ead37c --- /dev/null +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt @@ -0,0 +1,25 @@ +package org.openedx.whatsnew.presentation.whatsnew + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import org.openedx.core.BaseViewModel +import org.openedx.whatsnew.WhatsNewManager +import org.openedx.whatsnew.domain.model.WhatsNewItem + +class WhatsNewViewModel( + val courseId: String?, + private val whatsNewManager: WhatsNewManager +) : BaseViewModel() { + + private val _whatsNewItem = mutableStateOf(null) + val whatsNewItem: State + get() = _whatsNewItem + + init { + getNewestData() + } + + private fun getNewestData() { + _whatsNewItem.value = whatsNewManager.getNewestData() + } +} diff --git a/whatsnew/src/main/res/drawable-nodpi/screen_1.png b/whatsnew/src/main/res/drawable-nodpi/screen_1.png new file mode 100644 index 000000000..1853bf8ab Binary files /dev/null and b/whatsnew/src/main/res/drawable-nodpi/screen_1.png differ diff --git a/whatsnew/src/main/res/drawable-nodpi/screen_2.png b/whatsnew/src/main/res/drawable-nodpi/screen_2.png new file mode 100644 index 000000000..36b711417 Binary files /dev/null and b/whatsnew/src/main/res/drawable-nodpi/screen_2.png differ diff --git a/whatsnew/src/main/res/drawable-nodpi/screen_3.jpg b/whatsnew/src/main/res/drawable-nodpi/screen_3.jpg new file mode 100644 index 000000000..1165ae596 Binary files /dev/null and b/whatsnew/src/main/res/drawable-nodpi/screen_3.jpg differ diff --git a/whatsnew/src/main/res/raw/whats_new.json b/whatsnew/src/main/res/raw/whats_new.json new file mode 100644 index 000000000..4bf14e6e1 --- /dev/null +++ b/whatsnew/src/main/res/raw/whats_new.json @@ -0,0 +1,37 @@ +[ + { + "version": "1.0", + "messages": [ + { + "image": "screen_1", + "title": "Improved language support", + "message": "We have added more translations throughout the app so you can learn on edX your way!" + }, + { + "image": "screen_2", + "title": "Download videos offline", + "message": "Easily download videos without having an internet connection, so you can keep learning when there isn’t a network around" + }, + { + "image": "screen_2", + "title": "Reduced Network Usage", + "message": "Now you can download your content faster to get right into your next lesson!" + }, + { + "image": "screen_3", + "title": "Learning Site Switching", + "message": "Switch more easily between multiple learning sites. Find the new options within account settings and easily manage your accounts" + } + ] + }, + { + "version": "0.9", + "messages": [ + { + "image": "screen_1", + "title": "Sync to calendar", + "message": "Never miss a deadline again—sync course dates to your phone's calendar to receive reminders!" + } + ] + } +] diff --git a/whatsnew/src/main/res/values-uk/strings.xml b/whatsnew/src/main/res/values-uk/strings.xml new file mode 100644 index 000000000..d1ad95a41 --- /dev/null +++ b/whatsnew/src/main/res/values-uk/strings.xml @@ -0,0 +1,7 @@ + + + Що нового + Попередній + Наступний + Закрити + \ No newline at end of file diff --git a/whatsnew/src/main/res/values/strings.xml b/whatsnew/src/main/res/values/strings.xml new file mode 100644 index 000000000..135605767 --- /dev/null +++ b/whatsnew/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + What\'s New + Previous + Next + Done + \ No newline at end of file diff --git a/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt b/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt new file mode 100644 index 000000000..e187ffaa8 --- /dev/null +++ b/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt @@ -0,0 +1,29 @@ +package org.openedx.whatsnew + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.openedx.whatsnew.domain.model.WhatsNewItem +import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel + +class WhatsNewViewModelTest { + + private val whatsNewManager = mockk() + + private val whatsNewItem = WhatsNewItem( + version = "1.0.0", + messages = emptyList() + ) + + @Test + fun `getNewestData success`() = runTest { + every { whatsNewManager.getNewestData() } returns whatsNewItem + + val viewModel = WhatsNewViewModel("", whatsNewManager) + + verify(exactly = 1) { whatsNewManager.getNewestData() } + assert(viewModel.whatsNewItem.value == whatsNewItem) + } +} \ No newline at end of file